Empirical verification on NixOS/Linux with /dev/kvm + smolvm 1.4.7:
1. DB path confirmed: ~/.local/share/smolvm/server/smolvm.db exists,
schema matches (vms table, data BLOB with allowed_cidrs JSON field).
2. smolvm 1.4.7 still silently drops --allow-cidr with --from
(allowed_cidrs=None in DB after create). force_allowlist patch
is still necessary and correct.
3. All 5 sandbox-escape attacks blocked with BOT_BOTTLE_BACKEND=smolmachines
on Linux: hostname/IP allowlist, HTTP DLP, DNS exfil, git-gate gitleaks.
Changes:
- test_sandbox_escape: remove darwin-only guard; allow linux too
- contrib/claude/Dockerfile: add dnsutils (dig) for attack 4 DNS test
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Cover the new production lines added on this branch:
- cli/__init__: MissingEnvVarError maps to return 1
- cli/start: no-agents-defined path, prepare_with_preflight calls
render_preflight, _text_render_preflight backend name output
- env: resolve_env raises MissingEnvVarError for missing interpolated var
- smolmachines/launch: _proxy_host Linux/non-Linux branches,
_discover_urls git-gate host and supervise URL stamping
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- test_smolmachines_launch_image: save mock now touches the tarball path
so tarball.chmod(0o644) doesn't fail with FileNotFoundError
- test_smolmachines_launch (integration): remove unused `re` import
(pyright reportUnusedImport)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Two fixes for the Linux smolmachines backend:
- provision_ca: add mkdir -p before cp_in so the
/usr/local/share/ca-certificates/ directory exists in the smolvm
guest even when smolvm pack omits it (empty dir on Linux).
- git_http_backend: the fallback import in the except ImportError
block tried `from git_gate import ...`, but git_gate.py is not
deployed to the sidecar container and has its own package-relative
imports; use the literal value 15 directly instead.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace die()/RuntimeError at the point of detection with a typed
MissingEnvVarError, and catch it in the CLI dispatcher alongside
ManifestError. Library code stays free of stderr output; the CLI
renders all user-facing error lines in one place.
Sites updated: egress.egress_resolve_token_values (unset/empty token),
git_gate._provision_dynamic_key and revoke_git_gate_provisioned_keys
(missing forge_token_env), env.resolve_env (unset interpolated var).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Previously start silently exited 0 when the manifest had no agents,
leaving the user with no feedback.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
On Linux the guest kernel's LOCAL routing table routes all
127.0.0.0/8 to the guest's own loopback interface (priority 0,
checked before any main-table route), so TSI never sees
connections to the per-bottle loopback alias — the fix_guest_
loopback_routing approach confirmed this at the kernel level.
Use the per-bottle docker bridge gateway (192.168.N.1) instead.
It is not a loopback address, so the guest routes it via eth0
and TSI intercepts it normally. The TSI allowlist remains a
/32 that is distinct from the container IP (192.168.N.2), so
direct bypass to egress:9099 is still blocked by TSI.
Changes:
- Add _proxy_host() helper: returns bundle_gateway on Linux,
loopback alias on macOS
- Thread proxy_host through _start_bundle, _discover_urls,
_launch_vm, and _bundle_launch_spec (publish_host_ip)
- Remove _fix_guest_loopback_routing (no longer needed)
- Relax proxy-URL assertion in the integration test to accept
any http://IP:port (with a comment explaining the difference)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Port the smolmachines backend so BOT_BOTTLE_BACKEND=smolmachines
works on Linux (KVM), not just macOS:
- Preflight gates /dev/kvm presence + accessibility on Linux with
actionable remediation (kvm module, kvm group).
- smolvm state-DB path is platform-derived (XDG on Linux).
- force_allowlist runs on both platforms and is fail-closed: it
verifies the persisted TSI allowlist and dies rather than booting
a VM whose egress confinement it can't confirm. Previously it
no-oped on Linux, failing OPEN.
- allocate() does per-bottle 127.0.0.<N> scoping on Linux too (no
ifconfig needed — all of 127/8 is already loopback); only
ensure_pool's lo0 aliasing stays macOS-only.
- README documents Linux + NixOS host setup.
Linux/KVM integration (the sandbox-escape acceptance gate) is
pending verification on a NixOS host; unit tests cover the new
platform branches.
Issue: #283
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NkwFXLFff9PYPy4wgVBJp9
Design for porting the smolmachines backend off macOS-only: KVM
preflight, platform-aware smolvm state-DB path, fail-closed TSI
allowlist enforcement, and per-bottle loopback scoping on Linux.
NixOS is the primary validation target.
Issue: #283
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NkwFXLFff9PYPy4wgVBJp9
Apple's `container run --mount type=bind` only accepts directory
sources — a file source fails with "path '<file>' is not a
directory". Move the sqlite db into its own ~/.bot-bottle/db/
subdirectory so it can be bind-mounted the same way the CA/routes
mounts already are, without exposing the rest of ~/.bot-bottle.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Move _MIGRATIONS from module scope into AuditStore.__init__ and
QueueStore.__init__, treating table schema as a hidden implementation
detail of each store class.
Proposal, Response, AuditEntry, host_db_path, bot_bottle_root, and their
shared constants/helpers are moved to a new _supervise_types.py module.
queue_store and audit_store now import these directly instead of deferring
through get_supervise_mod() at call time.
host_db_path() in _supervise_types uses sys.modules[__name__].bot_bottle_root()
so monkey-patches on _supervise_types.bot_bottle_root propagate through it.
supervise.host_db_path() is re-defined to call bot_bottle_root() through
supervise's own globals, preserving patchability for callers that go through
the supervise module.
Test fixtures that create stores without an explicit db_path now patch
_sv_types.bot_bottle_root alongside supervise.bot_bottle_root. The server
test patches both flat and package _supervise_types since handle_tools_call
runs in the package context while the responder thread uses the flat module.
Dockerfile.sidecars gets a COPY for _supervise_types.py so the sidecar
bundle retains its flat import chain.
Follow-up to issuecomment-2872 on PR #320.
Remove auto-migration from DbStore.__init__; add explicit check_migrations()
and migrate() methods. The CLI now checks both stores on every startup and
prompts the user to confirm before migrating. supervise.prepare() calls
.migrate() directly now that __init__ no longer does it implicitly.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds bot_bottle/migrations.py (TableMigrations) and bot_bottle/db_store.py
(DbStore) per PR review. Both stores now inherit from DbStore and hold a
TableMigrations instance instead of duplicating schema-version logic inline.
Per review #320 comments:
- Rename _sv() → get_supervise_mod() in both store files (review 206/211)
- Move _audit_entry_from_row onto AuditStore as _row_to_entry static method
(review 208); move _proposal/_response_from_row onto QueueStore (review 211)
- Remove _host_db_path() free function; inline into __init__ (review 209/211)
- Add stdlib migration runner using a shared schema_versions table; each store
tracks its own version under a module key so they can coexist in the same DB
without clobbering a shared PRAGMA user_version (reviews 210/212/213)
- PRD: add goal 6 (migration runner), narrow non-goal to third-party ORM only
Add 8 tests covering the branches that were keeping diff-coverage below
90%: explicit db_path constructor arg, early-return guards when the DB
file is absent, _chmod OSError swallowing in both store classes, and the
supervise volume/env/daemon path in _bundle_launch_spec.
Diff-coverage rises from 89.2% to 94.6% (176/186 changed lines).
Moves _QueueStore → bot_bottle/queue_store.py (public QueueStore) and
_AuditStore → bot_bottle/audit_store.py (public AuditStore). Removes
the public queue_db_path() function; QueueStore resolves the DB path
via host_db_path() on the host, or via the SUPERVISE_DB_PATH env var
in the sidecar container (internal mechanism, not public API).
Adds queue_store.py and audit_store.py to Dockerfile.sidecars so the
sidecar bundle picks them up. Updates __all__ in supervise.py.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
git_http_backend.py is copied flat into the sidecar bundle image as a
standalone script, not as part of the bot_bottle package, and
git_gate.py/git_gate_render.py are never copied in. Its relative
import of GIT_GATE_TIMEOUT_SECS crashed the git-http daemon (port
9420) on every startup, silently leaving the smart-HTTP git-gate
transport down while the other sidecar daemons stayed up.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
`--headless` is a non-interactive launch path for `cli.py start`:
agent, bottles, label, and color come from flags + manifest defaults
with no TUI selectors and no y/N preflight (auto-confirmed via a new
`assume_yes` param threaded into the shared `_launch_bottle` core).
- `--bottle` (repeatable) defaults to the agent's own `bottle:`;
`--label` defaults to the agent name and auto-uniquifies on slug
collision; `--color` defaults to none.
- `--prompt TEXT` is required in headless mode and is delivered to the
agent via a new `headless_prompt(prompt)` method on `AgentProvider`,
implemented for claude (`-p`), codex (positional), and pi (`-p`).
- The agent still execs on inherited stdio/PTY, so whatever allocates
the PTY drives the live session; only the launch chrome is headless.
- `--headless --dry-run` previews the resolved plan without launching.
Adds unit coverage in tests/unit/test_cli_start_headless.py and
headless_prompt tests for each provider. Also stubs headless_prompt on
the in-test AgentProvider subclasses so the unit suite collects cleanly.
Closes#315.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WL77TgFxKbs3cidGMG9dz7
Skill names become host/guest path segments interpolated into the
`bottle.exec` shell strings in each contrib provider's provision_skills.
They were validated only as strings, so a name with shell metacharacters
or path traversal could reach the command.
Layer two defenses:
- Primary: reject any skill name that isn't kebab-case
([a-z][a-z0-9-]*) at manifest load, reusing the convention already
enforced on bottle/agent filenames (new is_valid_entity_name helper
in manifest_schema). Fails loud and early, protecting every consumer
of the name — not just the exec call sites.
- Failsafe: shlex.quote the interpolated skills_dir / dst paths in the
claude, codex, and pi providers, so a future unvalidated field can't
inject shell metacharacters even if it bypasses the load-time check.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NkwFXLFff9PYPy4wgVBJp9
manifest.py imported the extends/loader resolvers, while those resolvers
needed ManifestBottle back from manifest.py — a true bidirectional cycle
papered over with in-function imports and TYPE_CHECKING guards (not clear
dependency inversion).
Extract ManifestBottle into a new leaf module manifest_bottle.py that depends
only on the other leaf modules (manifest_util/agent/egress/git/schema).
manifest.py re-exports ManifestBottle, so `from .manifest import ManifestBottle`
callers are unaffected. With the cycle gone:
- manifest_extends and manifest_loader import ManifestBottle from
manifest_bottle and their other deps from the real source modules, all at
top level (TYPE_CHECKING block removed).
- manifest.py imports the extends/loader/schema/yaml_subset/log helpers at
module top; all per-function lazy imports in the cluster are removed.
No behavior change; full unit suite green, pyright clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NkwFXLFff9PYPy4wgVBJp9
- dlp_detectors._closest_pair: replace the O(n*m) cross product with an
O(n log n) sort + O(n) two-pointer merge, and early-out once a pair
falls within the proximity threshold. The inputs are attacker-controlled
response-body matches past the body-size cap, so the quadratic form was a
latent DoS. Extract _match_gap to share the span-gap calc with the caller.
- dlp_detectors._compute_encoded_variants: back the memo with a bounded
functools.lru_cache instead of an unbounded module dict, so a long-lived
proxy seeing rotating secrets evicts rather than growing without limit.
- supervise_server: extract the duplicated routes.yaml inputSchema into
_proposal_input_schema()/_ROUTES_YAML_DESCRIPTION so the egress-allow and
egress-block tools can't drift.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NkwFXLFff9PYPy4wgVBJp9
The pyright "0 errors" and pylint "9.93/10" badges were static,
hand-synced shields that duplicated state the `lint` CI job already
enforces — a maintenance tax that could silently drift from reality.
Remove both badges from the README and strip the corresponding steps
(pylint/pyright runs, sed rewrites, commit-message lines, and the
`.pylintrc`/`pyrightconfig.json` path triggers) from the badge-update
workflow. Lint/type enforcement in CI is unchanged; only the published
badges go away. Coverage and core-coverage badges stay.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NkwFXLFff9PYPy4wgVBJp9
`ManifestIndex.load_for_agent` was a ~100-line method branching across
the eager (from_json_obj) and lazy (from disk) resolution modes, with
the git-user merge tail duplicated in both branches. Split into
`_load_for_agent_eager` / `_load_for_agent_lazy` behind a small
dispatcher and extract the shared tail into
`_manifest_with_merged_git_user`. No behavior change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NkwFXLFff9PYPy4wgVBJp9
Two per-request hot-path costs in the egress DLP scanner:
- `_encoded_variants` derived the full variant set (gzip + nine
encodings) for every provisioned secret on every redaction and
known-secret scan — once per host, path, header, and body. Cache it
per distinct secret; callers still get a fresh list so they can't
corrupt the shared cached tuple.
- `_find_partial_window` searched the text once per secret n-gram,
giving O(len(secret) * len(text)). Build the secret's n-gram set once
and sweep the text a single time: O(len(text)), no coverage loss.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NkwFXLFff9PYPy4wgVBJp9
update-badges.yml triggered on **.py / .pylintrc / pyrightconfig.json /
.coveragerc but not scripts/critical-modules.txt, so editing the core
module list alone wouldn't refresh the `core coverage` badge until the
next .py change. Add it to the push paths.
Closes#305
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NkwFXLFff9PYPy4wgVBJp9
git_gate.py (699 LOC) mixed three responsibilities. Split into:
- git_gate_render.py — pure host-side rendering: the gate constants,
GitGateUpstream, gitconfig/known-hosts rendering, and the entrypoint /
pre-receive / access-hook script builders.
- git_gate_provision.py — the gitea deploy-key lifecycle
(_provision_dynamic_key / revoke / _resolve_identity_file).
- git_gate.py — the GitGate ABC + GitGatePlan, now 169 LOC, re-exporting
all moved names (see __all__) so the 19 importers are unchanged.
Host-side only (not flat-bundled), so no sidecar import shim. The one
test that patched the internal `_provision_dynamic_key` lookup is
repointed to its new module (public API unchanged). The two new modules
are added to scripts/critical-modules.txt so the decompose doesn't move
security code out of the measured core — critical aggregate stays 95%
(git_gate 100%, render 100%, provision 97%).
Closes#303
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NkwFXLFff9PYPy4wgVBJp9