Compare commits

..

5 Commits

Author SHA1 Message Date
didericis-claude 5624025b02 ci(test): split integration into per-backend jobs
test / integration-docker (pull_request) Successful in 9s
test / unit (pull_request) Successful in 30s
lint / lint (push) Successful in 2m24s
test / integration-firecracker (pull_request) Has started running
test / coverage (pull_request) Has started running
tracker-policy-pr / check-pr (pull_request) Failing after 11s
Add separate `integration-docker` and `integration-firecracker` jobs,
each with an explicit BOT_BOTTLE_BACKEND env var, so the backend used
is visible in CI output and skipped backends surface as a distinct job
rather than silent unittest.skip lines.

- integration-docker: ubuntu-latest, BOT_BOTTLE_BACKEND=docker
- integration-firecracker: [self-hosted, kvm], BOT_BOTTLE_BACKEND=firecracker,
  same-repo PRs + push + workflow_dispatch only (untrusted fork PRs do
  not execute on the privileged KVM runner)
- coverage: same same-repo restriction; refs #414 for the planned
  follow-up that moves coverage to ubuntu-latest via artifact combination

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-18 14:46:38 -04:00
didericis-claude ed38c68b01 test(integration): lift GITEA_ACTIONS skip for Firecracker backend
The sandbox-escape test was unconditionally skipped when GITEA_ACTIONS=true,
which prevented Firecracker orchestration coverage from being measured even
when BOT_BOTTLE_BACKEND=firecracker is set on the KVM runner.

Narrow the skip to: GITEA_ACTIONS=true AND BOT_BOTTLE_BACKEND != firecracker.
When BOT_BOTTLE_BACKEND=firecracker the test is explicitly opted in to run on
the self-hosted KVM runner where the required /dev/kvm + TAP pool exist.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-18 14:46:38 -04:00
didericis-claude df24e5e955 ci(coverage): address review findings from PR #349
- Finding 1: set BOT_BOTTLE_BACKEND=firecracker on the coverage step so
  the integration suite actually exercises the Firecracker orchestration
  paths rather than defaulting to Docker
- Finding 2: restrict the coverage job to push+workflow_dispatch only;
  PR-controlled code no longer executes on the privileged KVM runner
  automatically — maintainers trigger workflow_dispatch for trusted PRs
- Finding 3: expand path filters to include workflow files, scripts, and
  README so changes to CI configuration trigger the workflow itself

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-18 14:46:38 -04:00
didericis-claude ea147edb6f ci(coverage): install dev requirements on the KVM runner
The self-hosted KVM runner is a persistent machine, so
--break-system-packages is inappropriate. Use --user instead so
coverage (and pyright/pylint for future jobs) land in ~/.local
and survive between runs without touching the system Python.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-18 14:46:38 -04:00
didericis 2b4382bce9 ci(coverage): run the diff-coverage gate on a self-hosted KVM runner
Re-land the coverage gate deferred from #343. The Firecracker VM/SSH
orchestration (~230 lines) is only exercised by the integration suite,
which needs /dev/kvm + the provisioned TAP/nft pool — a container runner
skips it and those lines read uncovered, so the 90% diff gate can't pass
on ubuntu-latest. Move the `coverage` job to a self-hosted `kvm` runner
with a firecracker-readiness preflight (binary + /dev/kvm + `backend
status`) so the integration test actually runs. Unit/lint stay on
ubuntu-latest. README documents the runner prerequisites.

Depends on a registered self-hosted runner labelled `kvm`; until one is
provisioned this gate will not run. See PRD 0069 / #348.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
2026-07-18 14:46:38 -04:00
7 changed files with 128 additions and 389 deletions
+94 -26
View File
@@ -4,16 +4,15 @@
# dependencies are required to execute it. Tests are split by directory:
#
# tests/unit/ — pure unit tests; always run
# tests/integration/ — need a reachable Docker daemon; skip cleanly
# (via tests/_docker.py:skip_unless_docker) when
# Docker isn't available on the runner
# tests/integration/ — need a reachable backend; skip cleanly when
# the backend isn't available on the runner
# tests/canaries/ — upstream regression canaries; run on a separate
# schedule (see canaries.yml), not here
#
# This workflow assumes the Gitea Actions runner exposes the host Docker
# socket to the job container so `docker` commands inside the job can
# reach the daemon. If that's not yet configured on the runner the
# integration tests will skip rather than fail.
# Integration tests run once per backend in separate jobs. Each job sets
# BOT_BOTTLE_BACKEND explicitly so the test suite uses the right backend.
# Backends that aren't available on the runner fail the preflight step
# rather than silently skipping inside the test output.
name: test
@@ -23,9 +22,16 @@ on:
- main
paths:
- '**.py'
- '.gitea/workflows/**.yml'
- 'scripts/**'
- 'README.md'
pull_request:
paths:
- '**.py'
- '.gitea/workflows/**.yml'
- 'scripts/**'
- 'README.md'
workflow_dispatch:
jobs:
unit:
@@ -48,7 +54,7 @@ jobs:
- name: Report unit coverage
run: python3 -m coverage report -m
integration:
integration-docker:
runs-on: ubuntu-latest
steps:
- name: Checkout
@@ -65,33 +71,95 @@ jobs:
echo "docker not on PATH — integration tests will skip"
fi
- name: Run integration tests
- name: Run integration tests (docker)
env:
BOT_BOTTLE_BACKEND: docker
run: python3 -m unittest discover -t . -s tests/integration -v
# Combined unit+integration coverage report (informational). See
# docs/decisions/0004-coverage-policy.md.
# Integration tests against the Firecracker backend. Runs on a self-hosted
# KVM runner (label `kvm`) where /dev/kvm and the TAP/nft pool are available.
#
# The hard diff-coverage gate (changed lines >= 90%) is DEFERRED: the
# Firecracker backend's VM/SSH orchestration is covered by the integration
# suite, which needs /dev/kvm + the provisioned TAP/nft pool — a
# container-based runner skips it and those lines read uncovered, so the
# gate can't pass here. Re-enabling it on a self-hosted KVM runner is
# tracked separately (see PRD 0069 / #348 and the ci-runner branch).
# Restricted to same-repo PRs, push to main, and workflow_dispatch — fork
# PRs don't execute untrusted code on the privileged runner.
#
# Runner prerequisites (provision once; see README "Firecracker on Linux"):
# `firecracker` on PATH, `/dev/kvm` accessible, Docker, cached kernel +
# static dropbear, and the pool as a persistent systemd unit.
integration-firecracker:
runs-on: [self-hosted, kvm]
if: >-
github.event_name == 'push' ||
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'pull_request' &&
github.event.pull_request.head.repo.full_name == github.repository)
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Preflight — Firecracker host is ready
run: |
command -v firecracker >/dev/null || {
echo "firecracker not on PATH — provision the runner (README: Firecracker on Linux)"; exit 1; }
test -e /dev/kvm || { echo "/dev/kvm missing — KVM not available on this runner"; exit 1; }
# `backend status` exits non-zero unless the TAP pool is up + no
# range overlap; it prints the exact `backend setup` fix.
python3 cli.py backend status --backend=firecracker
- name: Install dev requirements
run: python3 -m pip install --user -r requirements-dev.txt
- name: Run integration tests (firecracker)
env:
BOT_BOTTLE_BACKEND: firecracker
run: python3 -m unittest discover -t . -s tests/integration -v
# Combined unit+integration coverage + the diff-coverage gate (the hard
# gate: new/changed lines >= 90%). See docs/decisions/0004-coverage-policy.md.
#
# This runs on a self-hosted KVM runner (label `kvm`), NOT ubuntu-latest,
# because the Firecracker backend's subprocess/VM orchestration
# (launch/boot/SSH/isolation-probe) is covered by the integration suite,
# and that suite needs `/dev/kvm` + the provisioned TAP/nft pool — which a
# container-based runner doesn't have. On such a runner the firecracker
# integration test skips and its ~230 orchestration lines read as
# uncovered, so the gate can't pass there.
#
# Restricted to the same events as integration-firecracker (same-repo PRs,
# push, workflow_dispatch) for the same security reason.
#
# See #414 for the planned follow-up: artifact-based coverage combination
# (run tests once in their respective jobs, combine .coverage files here).
coverage:
runs-on: ubuntu-latest
runs-on: [self-hosted, kvm]
if: >-
github.event_name == 'push' ||
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'pull_request' &&
github.event.pull_request.head.repo.full_name == github.repository)
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
# No actions/setup-python: the runner image already ships Python 3.12,
# and older act_runner engines mishandle setup-python's PATH (coverage
# lands in one interpreter, `python3` resolves to another). Install
# straight into the ephemeral job container's system Python —
# --break-system-packages is safe because the container is disposable.
- name: Install dev requirements
run: python3 -m pip install --break-system-packages -r requirements-dev.txt
- name: Preflight — Firecracker host is ready
run: |
command -v firecracker >/dev/null || {
echo "firecracker not on PATH — provision the runner (README: Firecracker on Linux)"; exit 1; }
test -e /dev/kvm || { echo "/dev/kvm missing — KVM not available on this runner"; exit 1; }
# `backend status` exits non-zero unless the TAP pool is up + no
# range overlap; it prints the exact `backend setup` fix.
python3 cli.py backend status --backend=firecracker
- name: Combined coverage report (unit + integration)
- name: Install dev requirements
run: python3 -m pip install --user -r requirements-dev.txt
- name: Combined coverage (unit + integration, incl. firecracker)
env:
BOT_BOTTLE_BACKEND: firecracker
run: PYTHON=python3 bash scripts/coverage.sh critical
- name: Diff-coverage gate (changed lines >= 90%)
run: |
git fetch --no-tags origin main:refs/remotes/origin/main
python3 scripts/diff_coverage.py --base origin/main --min 90
+2
View File
@@ -90,6 +90,8 @@ BOT_BOTTLE_BACKEND=firecracker ./cli.py start <agent>
> **NixOS:** enable `virtualisation.docker`, ensure the KVM module is loaded (`boot.kernelModules = [ "kvm-intel" ];` or `kvm-amd`), and add your user to the `kvm` and `docker` groups. For the network pool, consume the flake module — `imports = [ inputs.bot-bottle.nixosModules.firecracker-netpool ]; services.bot-bottle-firecracker = { enable = true; owner = "you"; };` — then `nixos-rebuild switch` (imperative nft/TAP rules don't survive a rebuild; channel users can `imports = [ <bot-bottle>/nix/firecracker-netpool.nix ]`). `firecracker` isn't in nixpkgs by default as a user binary — install the release binary (pin the version) and put it on `PATH`.
> **CI:** the coverage gate (`.gitea/workflows/test.yml` → `coverage` job) runs on a self-hosted runner labelled `kvm`, because the Firecracker backend's VM/SSH orchestration is exercised only by the integration suite, which needs `/dev/kvm` + the provisioned pool (a container runner would skip it and read as uncovered). Provision that runner exactly like a normal Firecracker host — `firecracker` on `PATH`, `/dev/kvm`, Docker, the cached guest kernel + static dropbear, and the pool installed as the persistent systemd unit — then register it with the `kvm` label. The unit/lint jobs still run on `ubuntu-latest`.
```sh
./cli.py start <agent> # builds the image on first run, drops you into claude
```
+1 -8
View File
@@ -38,13 +38,6 @@ COMMANDS = {
"supervise": cmd_supervise,
}
# Commands that manage host prerequisites (or are otherwise store-free) and
# must run before — or without — a migrated DB. `backend` provisions/probes
# the host (TAP pool, /dev/kvm, firecracker) and never opens the store, so
# gating it on the schema breaks preflight on a fresh CI runner where stdin
# isn't a TTY and the migration prompt can't be answered.
NO_MIGRATION_COMMANDS = frozenset({"backend"})
def usage() -> None:
sys.stderr.write(f"usage: {PROG} <command> [args...]\n\n")
@@ -87,7 +80,7 @@ def main(argv: list[str] | None = None) -> int:
usage()
die(f"unknown command: {command}")
mgr = StoreManager.instance()
if command not in NO_MIGRATION_COMMANDS and not mgr.is_migrated():
if not mgr.is_migrated():
sys.stderr.write("bot-bottle: database schema is out of date\n")
sys.stderr.write("Migrate now? [y/N] ")
sys.stderr.flush()
+25 -258
View File
@@ -14,34 +14,22 @@ detectors, PRD 0017 / 0053), and git-push secret scanning is handled by
**gitleaks** in the git-gate. "pipelock" below has been replaced with the
current mechanism; it survives only in older PRDs as history.
Updated again 2026-07-18: six additional tools added (Cleanroom,
container-use, Docker sbx, Anthropic srt, Microsoft AGT, Open Agent
Passport); an **Agent-tailored policy** row added to the comparison table;
a separate Governance layers section added for AGT and OAP. See the
second addendum at the end.
## Summary
Fifteen projects surveyed across two categories: isolation/sandbox tools
and governance/pre-action authorization layers (the latter don't provide
VM or container isolation but do per-agent policy enforcement at the
tool-call level). None duplicate bot-bottle's combination of local
VM-per-bottle isolation, a declarative per-role manifest, per-agent
egress allowlist + outbound-content DLP, bottle/agent split, and the
composable `extends:` policy model. Three clusters stand out:
Nine projects surveyed. None duplicate bot-bottle's combination of
local VM-per-bottle isolation (Firecracker microVM on KVM Linux, Apple
Container on macOS — Docker is now only the legacy fallback), a
declarative JSON manifest, per-agent egress allowlist + outbound-content
DLP via bot-bottle's own egress scanner (plus gitleaks secret-scanning on
git push), and bottle/agent split. Two clusters stand out:
- **Closest neighbours** — agent-safehouse and litterbox: local,
single-user, thin wrappers over an existing OS primitive
(`sandbox-exec`, Podman + Landlock).
- **Different category (isolation)** — tilde.run (hosted SaaS), boxlite
and microsandbox (microVM libraries for platform builders), CubeSandbox
- **Different category** — tilde.run (hosted SaaS), boxlite and
microsandbox (microVM libraries for platform builders), CubeSandbox
(self-hosted multi-tenant microVM service), endo-familiar
(capability-security paradigm, no OS isolation).
- **New: governance/pre-action layers** — Microsoft AGT and Open Agent
Passport (OAP): framework-embedded tool-call interceptors with
per-agent declarative policy. Closest competitors on agent-tailored
policy, but operate at the tool-call level rather than providing
network/filesystem isolation; they complement rather than substitute.
The microVM cluster (matchlock, smolmachines, boxlite, microsandbox,
CubeSandbox) is the most relevant for the v2 isolation discussion in
@@ -222,157 +210,20 @@ claim that the monetization positioning leans on. See the addendum.
- **Maturity**: Open-sourced July 2026 off production Tencent Cloud use;
most-starred project in this set (~10.4k).
### Cleanroom *(added 2026-07-18)*
- **Source**: https://github.com/buildkite/cleanroom
- **License**: Apache 2.0
- **Isolation**: MicroVM — Firecracker on Linux, Virtualization.framework
on macOS. Digest-pinned OCI images.
- **Locality**: Self-hosted server (CI-oriented).
- **Agent integration**: Generic process sandbox; CI-first, not a
Claude/agent wrapper.
- **Config**: `cleanroom.yaml` in the repo being sandboxed defines egress
rules, resources, and network policy. Cleanroom resolves this from the
commit being run.
- **Network policy**: Default-deny + per-repo hostname allowlist (resolved
from DNS answers + destination IP:port). Co-hosted services on the same
IP:port are not distinguished. OIDC-backed auth for remote servers.
- **Credentials**: Host-side only; not injected in-flight but not present
in the VM.
- **Notable**: Policy lives in the *repo being sandboxed*, not in an
agent-role definition — closer to per-repo scoping than per-role.
Supports Docker-inside-sandbox (`services.docker.required: true`), OIDC
authorization, suspend/resume lifecycle.
- **Maturity**: Active Buildkite product.
### container-use *(added 2026-07-18)*
- **Source**: https://github.com/dagger/container-use
- **License**: Apache 2.0
- **Isolation**: Docker container per agent + git worktree per agent.
Containers share the host kernel; stronger than bare host but weaker
than microVM.
- **Locality**: Local.
- **Agent integration**: MCP stdio server — Claude Code, Cursor, Windsurf.
`claude mcp add container-use -- container-use stdio`.
- **Config**: None for security policy. Environments are provisioned on
demand; no allowlist or credential config.
- **Network policy**: Not addressed.
- **Notable**: Per-agent git branches (`container-use/<env_name>`);
parallel agents without filesystem conflict; real-time log visibility
and terminal attach for intervention; git-based review workflow.
Oriented toward parallel development safety, not security containment.
- **Maturity**: Early development, active.
### Docker sbx *(added 2026-07-18)*
- **Source**: Docker proprietary (`sbx` CLI, separate from `docker`).
- **License**: Proprietary.
- **Isolation**: MicroVM (Docker's own implementation) — each session gets
its own kernel, Docker daemon inside the VM, and filesystem.
- **Locality**: Local (macOS and Windows; does not require Docker Desktop).
- **Agent integration**: Explicit wrapper — Claude Code, Codex, Gemini
CLI, Copilot CLI, Kiro. Launches agent inside the VM with
`--dangerously-skip-permissions` by default.
- **Config**: Open / Balanced / Locked Down network presets at launch. No
per-role manifest.
- **Network policy**: Default-deny; preset levels control strictness. TUI
dashboard shows a live log of every outbound connection (allowed and
blocked) with point-and-click allow/block for hosts.
- **Credentials**: OS keychain + host-side proxy injection — API keys
never enter the VM.
- **Notable**: Best DX among microVM tools (one command, works like native
yolo Claude but inside a VM); branch mode creates a git worktree in
`.sbx/`. Network policy is preset-based, not role-declarative.
- **Maturity**: GA 2026.
### Anthropic srt *(added 2026-07-18)*
- **Source**: https://github.com/anthropic-experimental/sandbox-runtime
(`@anthropic-ai/sandbox-runtime` on npm, `sandbox-runtime` on PyPI)
- **License**: Apache 2.0 (experimental).
- **Isolation**: OS-level only — Seatbelt (`sandbox-exec`) on macOS,
bubblewrap on Linux, WFP (Windows Filtering Platform) account-fenced on
Windows. **No container or VM.** Lowest overhead in the set.
- **Locality**: Local.
- **Agent integration**: Claude Code's sandboxed bash tool uses this
internally. Can wrap any arbitrary process (`srt <command>`). Cloud
Claude Code sessions use full microVMs instead.
- **Config**: Programmatic per-invocation — allow/deny path lists for
filesystem; allow/denylist for network (HTTP proxy + SOCKS5).
- **Network policy**: Proxy-based filtering (HTTP + SOCKS5); domain
allowlist/denylist enforced at proxy layer. Custom proxy supported
(e.g. mitmproxy for inspection + audit). Processes that ignore proxy
env vars may bypass filtering on some platforms.
- **Notable**: Cross-platform (macOS/Linux/Windows); wraps any process,
not just agents; no role/manifest concept. Annotated as a research
preview — APIs may change.
- **Maturity**: Early research preview.
## Governance / pre-action authorization layers
These two tools don't provide VM or filesystem isolation; they intercept
tool calls before execution and evaluate them against a per-agent
declarative policy. They are the closest competitors on **agent-tailored
policy** and complement isolation sandboxes rather than substituting for
them.
### Microsoft Agent Governance Toolkit (AGT) *(added 2026-07-18)*
- **Source**: https://github.com/microsoft/agent-governance-toolkit
- **License**: MIT (~3.3k stars, open-sourced April 2, 2026).
- **Isolation**: None (OS/VM). Execution rings (03, inspired by CPU
privilege levels) control what an agent can do at the framework layer.
MCP security gateway treats MCP traffic as an untrusted boundary.
- **Locality**: Embedded in the agent framework (Python, TypeScript, .NET,
Rust, Go; 20+ framework adapters).
- **Agent integration**: Framework-agnostic. Plugs into Semantic Kernel,
AutoGen, and others as a middleware layer.
- **Config**: YAML policy per agent — tools can be `allowed`, `denied`,
`sandboxed`, or routed through an `approval` step. Every action passes
through a governance gate checking: agent DID, trust score, risk tier,
requested tool, action type, and policy rules.
- **Network policy**: Not directly — operates at tool-call level.
- **Credentials**: Per-agent DID (Ed25519 decentralized identifier); agent
does not borrow a human's credentials.
- **Notable**: Dynamic trust score (01,000, behavioral decay) —
privilege follows observed behaviour, not just provisioning. Covers all
10 OWASP Agentic Top 10 risks. Kill switch + SLO monitoring. Sub-ms
policy enforcement.
- **Maturity**: MIT, ~3.3k ⭐, v3.7.0 May 2026.
### Open Agent Passport (OAP) *(added 2026-07-18)*
- **Source**: https://github.com/aporthq/aport-spec ; spec at
https://api.aport.io/spec/spec/oap/oap-spec.md/ ; arXiv 2603.20953
- **License**: Open specification.
- **Isolation**: None. Pre-action hook only — intercepts tool calls
synchronously before execution, evaluates against a cloud-registry
declarative policy, fails closed.
- **Locality**: Local hook + cloud policy registry.
- **Agent integration**: Framework-agnostic; hook pattern.
- **Config**: Declarative policy rules in a cloud registry (evaluated in
order; first failing rule denies). Ed25519-signed, hash-chained audit
records per decision.
- **Network policy**: Not directly.
- **Notable**: 53ms median authorization decision (N=1,000). In an
adversarial testbed ($5,000 bounty, 1,151 sessions), social engineering
succeeded 74.6% of the time under a permissive policy; under a
restrictive OAP policy, 0% success across 879 attempts. Assumes
framework runtime is not compromised.
- **Maturity**: Specification + reference implementation, 2026.
## Comparison table
*Isolation/sandbox tools only. AGT and OAP are governance layers — see their per-project notes above.*
| Axis | bot-bottle | endo-familiar | litterbox | agent-safehouse | matchlock | tilde.run | boxlite | microsandbox | smolmachines | CubeSandbox | Cleanroom | container-use | Docker sbx | Anthropic srt |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Isolation | MicroVM per bottle default (Firecracker/KVM on Linux, Apple Container on macOS) + own egress DLP scanner; Docker legacy fallback, gVisor there if present | Object-capability (no OS isolation) | Podman + opt. Landlock | macOS `sandbox-exec` | MicroVM (Firecracker / Virt.fw) | Hosted container (unverified) | MicroVM (KVM / Hypervisor.fw) | MicroVM (libkrun) | MicroVM (libkrun / KVM) | MicroVM (RustVMM / KVM) | MicroVM (Firecracker / Virt.fw) | Docker container + git worktree | MicroVM (proprietary) | OS-level (Seatbelt / bubblewrap / WFP) — no container |
| Local vs hosted | Local | Local | Local (Linux) | Local (macOS) | Local | Hosted SaaS | Local | Local | Local | Self-hosted (server/cluster) | Self-hosted server | Local | Local | Local |
| Open source | Apache 2.0 | Apache 2.0 | Apache 2.0 | Apache 2.0 | MIT | No | Apache 2.0 | Apache 2.0 | Apache 2.0 | Apache 2.0 | Apache 2.0 | Apache 2.0 | Proprietary | Apache 2.0 (experimental) |
| Agent target | Claude Code | Generic (demo) | Generic | Multi-agent wrapper | Generic (+ Claude/OpenAI SDKs) | Claude focus | Generic | Claude + Cursor (MCP/Skills) | Generic (AGENTS.md) | E2B-compatible (platform builders) | CI / generic process | Claude Code, Cursor, Windsurf (MCP) | Claude Code, Codex, Gemini CLI, Copilot, Kiro | Claude Code (and any process) |
| Network policy | Default-deny via own egress scanner + per-bottle allowlist + content DLP + gitleaks on git push | Capability model only | Limited | Not addressed | Default-deny + allowlist + secret-injecting proxy | Default-deny + logging | Per-VM net (unverified) | Not documented | Off by default + allowlist | Default-deny allowlist + instant egress block + audit logs + per-sandbox tokens (eBPF) + credential vault | Default-deny + per-repo host allowlist (cleanroom.yaml) | Not addressed | Default-deny; Open / Balanced / Locked Down presets; live TUI network panel | Proxy-based allowlist/denylist (HTTP + SOCKS5); custom proxy supported |
| Parallel agents | Yes (one bottle per agent) | n/a | Not addressed | One at a time | Multiple VMs | Yes (dashboard) | SDK-level | SDK-level | Architectural | Yes (2,000+/host claimed) | Yes (server model) | Yes (per-agent containers + worktrees) | Yes | Yes |
| Long-running posture | Persistent by default (named, supervised) | n/a (demo) | Session (up while in use) | Per-invocation | Ephemeral VM per run | Per-run (versioned) | Ephemeral + snapshot/fork | Ephemeral / on-demand | Named persistent by default | Ephemeral + auto pause/resume | Per-run + suspend/resume | Per-agent container (ephemeral) | Per-session; branch mode creates git worktree in .sbx/ | Per-invocation |
| DX: run Claude yolo-style | One command → interactive yolo Claude (`start <agent>`, `--dangerously-skip-permissions` default) | n/a (lib demo) | Wizard + build, then run claude inside (Linux only) | One-command wrapper (`safehouse claude --dangerously-skip-permissions`) | CLI: run a cmd in a VM (not a Claude wrapper) | Hosted (`tilde exec`), not local-native | SDK code required (build the run yourself) | CLI/MCP: sandbox-as-a-tool for the agent, not a wrapper around it | SSH into a named machine, run claude there | Stand up a cluster + drive via E2B SDK | CI-oriented, not a Claude wrapper | MCP server: `claude mcp add container-use -- container-use stdio` | One command: `sbx` wraps claude with `--dangerously-skip-permissions` default | Library/wrapper, not a standalone CLI |
| Config | JSON manifest (bottles + agents) | Programmatic refs | CLI wizard | Profile files / shell fns | CLI / SDK | DSL + CLI + SDK | SDK | CLI / SDK / MCP | TOML Smolfile | E2B-compatible SDK | cleanroom.yaml in repo | None (no policy config) | Preset levels at launch | Programmatic per-invocation (allow/deny lists) |
| Agent-tailored policy | Yes — bottle/agent split; declarative per-role egress + credentials; composable via `extends:` | Partial — capability model scopes per-agent, but no declarative role manifest | No | Partial — per-agent profile files (Seatbelt); no egress | No | Yes — per-agent DSL RBAC (allow/deny/approve per action/repo/agent) | No | No | No | No — per-sandbox SDK config, not role-scoped | Partial — per-repo cleanroom.yaml, not per-role | No | No — network presets only | No |
| Maturity | Active July 2026 | Research (2022+) | Early (~66 ⭐) | Active (~1.4k ⭐) | Experimental (~574 ⭐) | Private preview | YC, ~4.7k ⭐ | YC, ~6k ⭐, beta | ~3.1k ⭐ | Tencent, prod, ~10.4k ⭐ | Active (Buildkite product) | Early development | GA 2026 | Early research preview |
| Axis | bot-bottle | endo-familiar | litterbox | agent-safehouse | matchlock | tilde.run | boxlite | microsandbox | smolmachines | CubeSandbox |
|---|---|---|---|---|---|---|---|---|---|---|
| Isolation | MicroVM per bottle default (Firecracker/KVM on Linux, Apple Container on macOS) + own egress DLP scanner; Docker legacy fallback, gVisor there if present | Object-capability (no OS isolation) | Podman + opt. Landlock | macOS `sandbox-exec` | MicroVM (Firecracker / Virt.fw) | Hosted container (unverified) | MicroVM (KVM / Hypervisor.fw) | MicroVM (libkrun) | MicroVM (libkrun / KVM) | MicroVM (RustVMM / KVM) |
| Local vs hosted | Local | Local | Local (Linux) | Local (macOS) | Local | Hosted SaaS | Local | Local | Local | Self-hosted (server/cluster) |
| Open source | Apache 2.0 | Apache 2.0 | Apache 2.0 | Apache 2.0 | MIT | No | Apache 2.0 | Apache 2.0 | Apache 2.0 | Apache 2.0 |
| Agent target | Claude Code | Generic (demo) | Generic | Multi-agent wrapper | Generic (+ Claude/OpenAI SDKs) | Claude focus | Generic | Claude + Cursor (MCP/Skills) | Generic (AGENTS.md) | E2B-compatible (platform builders) |
| Network policy | Default-deny via own egress scanner + per-bottle allowlist + content DLP + gitleaks on git push | Capability model only | Limited | Not addressed | Default-deny + allowlist + secret-injecting proxy | Default-deny + logging | Per-VM net (unverified) | Not documented | Off by default + allowlist | Default-deny allowlist + instant egress block + audit logs + per-sandbox tokens (eBPF) + credential vault |
| Parallel agents | Yes (one bottle per agent) | n/a | Not addressed | One at a time | Multiple VMs | Yes (dashboard) | SDK-level | SDK-level | Architectural | Yes (2,000+/host claimed) |
| Long-running posture | Persistent by default (named, supervised) | n/a (demo) | Session (up while in use) | Per-invocation | Ephemeral VM per run | Per-run (versioned) | Ephemeral + snapshot/fork | Ephemeral / on-demand | Named persistent by default | Ephemeral + auto pause/resume |
| DX: run Claude yolo-style | One command → interactive yolo Claude (`start <agent>`, `--dangerously-skip-permissions` default) | n/a (lib demo) | Wizard + build, then run claude inside (Linux only) | One-command wrapper (`safehouse claude --dangerously-skip-permissions`) | CLI: run a cmd in a VM (not a Claude wrapper) | Hosted (`tilde exec`), not local-native | SDK code required (build the run yourself) | CLI/MCP: sandbox-as-a-tool for the agent, not a wrapper around it | SSH into a named machine, run claude there | Stand up a cluster + drive via E2B SDK |
| Config | JSON manifest (bottles + agents) | Programmatic refs | CLI wizard | Profile files / shell fns | CLI / SDK | DSL + CLI + SDK | SDK | CLI / SDK / MCP | TOML Smolfile | E2B-compatible SDK |
| Maturity | Active May 2026 | Research (2022+) | Early (~66 ⭐) | Active (~1.4k ⭐) | Experimental (~574 ⭐) | Private preview | YC, ~4.7k ⭐ | YC, ~6k ⭐, beta | ~3.1k ⭐ | Tencent, prod, ~10.4k ⭐ |
## What's closest, what's different
@@ -382,41 +233,11 @@ existing OS primitive, low-dep. The split is the isolation primitive —
bot-bottle now defaults to a VM per bottle (Firecracker microVM on KVM
Linux, Apple Container on macOS) with its own DLP-scanning egress proxy,
keeping Docker only as a legacy fallback; agent-safehouse uses
`sandbox-exec`; litterbox uses Podman + Landlock. matchlock and
smolmachines are close on *both* the policy side (default-deny net,
per-host allowlist) and — now that bot-bottle has moved off
containers-by-default — the microVM isolation primitive.
**New closest on agent-tailored policy.** Two governance tools are the
direct competitors on the "coarse-grained sandbox" axis. **tilde.run**
has had per-agent DSL RBAC since its launch (though it's hosted SaaS).
**Microsoft AGT** is the most serious new entrant: per-agent DID
identity, YAML policy that can allow/deny/sandbox/approve individual tool
calls per agent, and a dynamic behavioural trust score. It operates at
the framework tool-call layer, not the network layer — so it's
complementary to bot-bottle's network/filesystem isolation rather than a
direct substitute, but on the "does this sandbox know what this agent is
for?" question it is the most complete answer in the field. OAP's
pre-action hook pattern achieves similar goals with cryptographic audit
and a 0% adversarial-attack success rate under a restrictive policy.
**New closest on DX.** **Docker sbx** is the first tool in this set that
matches bot-bottle on the "one command, dangerously-skip-permissions safe
by default" DX bar, at microVM isolation strength, with host-side
credential injection. It is proprietary, preset-based (not role-
declarative), and cloud-agent-specific, but it directly competes on the
UX proposition. agent-safehouse was the previous DX peer; Docker sbx
materially raises the bar.
**New closest on repo-scoped policy.** **Cleanroom** (Buildkite) is the
first tool to combine microVM isolation with a declarative egress policy
file — though the policy lives in the repo being sandboxed
(`cleanroom.yaml`), not in an agent-role manifest. That makes it per-
repo rather than per-role: the same Cleanroom config applies to any
agent running in that repo. The distinction matters for bot-bottle's
use case (one developer running multiple agent *roles* with different
egress footprints), but for CI/CD use cases Cleanroom is a direct
alternative.
`sandbox-exec`; litterbox
uses Podman + Landlock. matchlock and smolmachines are close on *both* the
policy side (default-deny net, per-host allowlist) and — now that
bot-bottle has moved off containers-by-default — the microVM isolation
primitive.
**Solving a different problem.** tilde.run is hosted SaaS for team /
production agent pipelines with data-versioned rollback — explicitly
@@ -588,57 +409,3 @@ Why it matters anyway:
and per-sandbox egress tokens (eBPF virtual switch vs. bot-bottle's
mitmproxy egress proxy) before the next iteration of bot-bottle's
in-flight-secret feature — see borrowable idea #2 above.
## Addendum 2026-07-18 (second pass) — agent-tailored policy landscape
The second-pass question was: how novel is bot-bottle's per-agent,
role-tailored sandbox relative to the expanded field?
**The short answer:** on the isolation + network + role-tailoring
combination, bot-bottle remains the only tool in this set. On
role-tailored *policy at the tool-call level*, Microsoft AGT and OAP are
the most complete answers, but they don't provide isolation; they
complement rather than substitute.
**The competitive picture by axis:**
- *Agent-tailored egress (declarative, per-role)* — bot-bottle and
tilde.run. Cleanroom is per-repo, not per-role. Everyone else is
per-session or not addressed.
- *Agent-tailored tool-call policy (declarative, per-agent identity)* —
Microsoft AGT (YAML policy + DID identity + trust score), OAP
(declarative policy rules + cryptographic audit). Neither provides
network/filesystem isolation.
- *Composable policy (role overlays)* — bot-bottle (`extends:`). No
other tool surveyed supports composable role-policy inheritance.
- *Isolation + DX (one-command safe yolo)* — bot-bottle and Docker sbx.
Docker sbx is proprietary, preset-based, and cloud-agent-specific;
it's the first DX-class competitor at microVM isolation strength.
**What the HN "coarse-grained" complaint maps to:** The complaint is
that a VM isolates the filesystem but doesn't know if the agent
*should* be sending an email. bot-bottle's bottle/agent split is a
structural answer to this: the bottle manifest declares exactly what
the role can reach, and the sandbox enforces it at the network layer.
Microsoft AGT is the most complete answer at the semantic/tool-call
layer. The gap both leave open is *intent classification* — knowing
whether a permitted action is consistent with the agent's actual task.
See `hn-agent-safety-discourse-july-2026.md` for the blast-radius
analysis.
**Borrowable from new tools:**
- **Microsoft AGT's trust-score decay** — privilege that reflects
observed behaviour rather than static provisioning. Applied to
bot-bottle: a bottle that has triggered DLP alerts or supervise holds
could auto-downgrade its network preset, or flag the session for
closer review. Fits the existing supervise-server architecture.
- **Docker sbx's live network TUI** — real-time per-session view of
allowed and blocked outbound connections with point-and-click
allow/block. `cli.py supervise` is the right surface; adding a
live-connections panel would directly address the "I can't see what
the agent is doing" gap without any backend changes.
- **OAP's cryptographic audit chain** — Ed25519-signed, hash-chained
audit records. Currently bot-bottle logs egress decisions but doesn't
chain them. A tamper-evident audit record per session would be useful
for the compliance use case the CubeSandbox positioning targets.
@@ -204,43 +204,6 @@ MCP STDIO server from within the agent is still sandboxed by the VM, and
any outbound calls from that server must pass the egress allowlist and
outbound DLP scanner.
**Per-agent role tailoring (the "coarse-grained sandbox" complaint)**
The Feb 2026 HN thread that argued "sandboxes are too coarse-grained"
was pointing at a real gap: a VM isolates the filesystem but doesn't
know whether an agent *should* be sending email or calling an external
API. bot-bottle's bottle/agent split is a structural answer at the
network layer — the bottle manifest declares exactly what each role can
reach (which hosts, which paths, which HTTP methods), and the egress
scanner enforces it. A `gitea-dev` bottle that only lists
`gitea.dideric.is` and `api.anthropic.com` structurally cannot send
email or reach AWS, not because the model was told not to, but because
those routes don't exist.
The `extends:` composition model means provider-level policy (the Claude
auth route) lives in one base bottle and role-specific overlays are
stacked on top — no duplication, and changing the base propagates to all
derived roles.
Competitive position on this axis (from `agent-sandbox-landscape.md`):
| Tool | Agent-tailored policy |
|---|---|
| **bot-bottle** | Yes — declarative per-role manifest; `extends:` composition; egress + credentials scoped to role |
| **tilde.run** | Yes — per-agent DSL RBAC (allow/deny/approve per action/repo/agent), but hosted SaaS |
| **Microsoft AGT** | Yes — YAML policy + per-agent DID + trust score, but tool-call level only (no network isolation) |
| **OAP** | Yes — declarative pre-action policy + cryptographic audit, but no isolation |
| **Cleanroom** | Partial — per-repo `cleanroom.yaml`, not per-role |
| **Docker sbx** | No — network presets only |
| **Anthropic srt** | No — programmatic per-invocation |
| **matchlock / smolmachines / microsandbox** | No |
| **agent-safehouse** | Partial — per-agent Seatbelt profiles; no egress |
Two takeaways: bot-bottle and tilde.run are the only isolation tools
with declarative role-tailored policy; Microsoft AGT and OAP are the
closest competitors on role-tailoring but operate at the tool-call layer
without network/filesystem isolation — complementary, not substitutes.
**Outbound exfiltration (any injection class)**
Whatever triggers the agent — README injection, Agentjacking, MCP
+6 -5
View File
@@ -69,11 +69,12 @@ _DUMMY_HOST_KEY = (
@skip_unless_docker()
@unittest.skipIf(
os.environ.get("GITEA_ACTIONS") == "true",
"skipped under act_runner: egress_tls_init uses a host bind mount "
"the runner container can't see, and the network topology hides "
"sibling-gateway visibility — same constraint as the other "
"bottle-bringup integration tests",
os.environ.get("GITEA_ACTIONS") == "true"
and os.environ.get("BOT_BOTTLE_BACKEND") != "firecracker",
"skipped under act_runner unless BOT_BOTTLE_BACKEND=firecracker: "
"egress_tls_init uses a host bind mount the runner container can't "
"see, and the network topology hides sibling-gateway visibility — "
"these constraints don't apply on the self-hosted KVM runner",
)
class TestSandboxEscape(unittest.TestCase):
"""End-to-end attacks against a real bottle. The bottle stays
-55
View File
@@ -95,60 +95,5 @@ class TestMainDispatch(unittest.TestCase):
self.assertEqual(130, main(["x"]))
class TestMigrationGate(unittest.TestCase):
"""The dispatcher's schema-migration gate (cli/__init__.py)."""
def setUp(self) -> None:
# Force the "schema out of date" branch for every test here.
patcher = patch.object(StoreManager, "is_migrated", return_value=False)
patcher.start()
self.addCleanup(patcher.stop)
self.addCleanup(StoreManager.reset)
def test_store_command_blocks_when_stdin_cannot_confirm(self) -> None:
# Non-TTY stdin at EOF (as in CI): the [y/N] prompt reads "" and the
# command is refused rather than migrating silently.
ran: list[bool] = []
def handler(_rest: list[str]) -> int:
ran.append(True)
return 0
with patch.dict(climod.COMMANDS, {"list": handler}), \
patch("sys.stdin", io.StringIO("")), \
patch("sys.stderr", io.StringIO()):
self.assertEqual(1, main(["list"]))
self.assertEqual([], ran, "gated command must not dispatch")
def test_backend_command_skips_gate(self) -> None:
# `backend` provisions/probes the host and never opens the store, so
# it must run even on an unmigrated DB with unanswerable stdin.
ran: list[bool] = []
def handler(_rest: list[str]) -> int:
ran.append(True)
return 0
with patch.dict(climod.COMMANDS, {"backend": handler}), \
patch("sys.stdin", io.StringIO("")), \
patch("sys.stderr", io.StringIO()):
self.assertEqual(0, main(["backend", "status"]))
self.assertEqual([True], ran, "exempt command must dispatch")
def test_store_command_migrates_on_confirmation(self) -> None:
migrated: list[bool] = []
def handler(_rest: list[str]) -> int:
return 0
with patch.dict(climod.COMMANDS, {"list": handler}), \
patch.object(StoreManager, "migrate",
side_effect=lambda: migrated.append(True)), \
patch("sys.stdin", io.StringIO("y\n")), \
patch("sys.stderr", io.StringIO()):
self.assertEqual(0, main(["list"]))
self.assertEqual([True], migrated, "confirmed gate must migrate")
if __name__ == "__main__":
unittest.main()