Compare commits

..

35 Commits

Author SHA1 Message Date
didericis 0adbf25977 test(firecracker): satisfy pyright on the new infra-VM tests
tracker-policy-pr / check-pr (pull_request) Successful in 18s
test / integration-docker (pull_request) Successful in 30s
test / unit (pull_request) Successful in 42s
lint / lint (push) Successful in 45s
test / integration-firecracker (pull_request) Successful in 44s
test / coverage (pull_request) Successful in 1m8s
Two lint fixes, both test-only (no effect on the infra artifact version):
- annotate the TestAdoptable / TestKillInfraFirecrackers helper params
  (reportMissingParameterType).
- test_docker_test_helpers: read __unittest_skip__ via getattr on the
  dynamically-built Case type, matching the sibling assertion — pyright can't
  see the attribute the skip decorator adds (reportAttributeAccessIssue).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9qa3xoavjQScufDfZaXKR
2026-07-19 01:31:06 -04:00
didericis d1aec706e3 fix(firecracker): fold guest init into the agent-rootfs cache key
tracker-policy-pr / check-pr (pull_request) Successful in 18s
test / integration-docker (pull_request) Successful in 35s
test / unit (pull_request) Successful in 40s
lint / lint (push) Failing after 45s
test / integration-firecracker (pull_request) Successful in 2m50s
test / coverage (pull_request) Successful in 2m52s
build_agent_rootfs_dir cached the built rootfs by Dockerfile content alone,
but util.inject_guest_boot then writes util._GUEST_INIT into it. So a fix to
the init — making /tmp world-writable (1777) so the agent can create scratch
dirs / git worktrees there — did NOT bust the cache: the KVM runner kept
reusing a stale agent-<dockerfilehash> rootfs built with the old init, and the
sandbox-escape README-push test kept failing at `git init /tmp/...` with
"Permission denied".

Key the cache on Dockerfile content AND the injected init (_rootfs_digest), so
an init change rebuilds. Self-busting: the new key yields a fresh cache dir, so
no manual cache clear on the runner.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9qa3xoavjQScufDfZaXKR
2026-07-19 01:21:23 -04:00
didericis a589604aa0 test(firecracker): derive netpool names from config, not hardcoded defaults
The KVM CI runner now sets BOT_BOTTLE_FC_* for its isolated pool (distinct
iface prefix / orch iface / nft table / IP base), and that env leaks into the
coverage job's test process. Five netpool tests hardcoded the default names
(bbfc*, bot_bottle_fc) and so failed there with e.g. ['bbfc1'] != ['bbci1'].

Assert against netpool's env-driven config instead — slot(i).iface / the
configured prefix — so the tests check the LOGIC regardless of which pool the
host is configured for. The single-source test now compares the parsed
defaults (netpool._DEFAULTS, env-independent) for the module constants, since
IFACE_PREFIX/NFT_TABLE legitimately layer an env override on top.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9qa3xoavjQScufDfZaXKR
2026-07-19 01:21:23 -04:00
didericis 4c01e31e96 fix(firecracker): version-aware infra-VM adoption + robust teardown
test / integration-docker (pull_request) Successful in 15s
tracker-policy-pr / check-pr (pull_request) Successful in 14s
test / coverage (pull_request) Failing after 35s
test / unit (pull_request) Successful in 40s
test / integration-firecracker (pull_request) Failing after 1m46s
lint / lint (push) Failing after 2m33s
The infra VM is a per-host singleton that outlives short-lived launchers, so
`ensure_running` adopts it when its control plane is healthy. But it adopted
ANY healthy VM regardless of the code that built it — so after an infra-code
change the old VM kept being adopted and the new code never booted. The only
way to dislodge it was an out-of-band `kill`, which then raced whatever
launched next. On CI this meant every infra change needed a manual VM kill.

Make adoption version-aware:

  * boot records the infra-artifact version it booted from in a `booted-version`
    marker beside the singleton; `stop` clears it.
  * `ensure_running` adopts only when the marker matches the current version
    (`_adoptable`); a missing/mismatched marker falls through to stop + reboot.
    So a stale VM is replaced automatically on the next launch — no manual kill,
    and it's concurrency-safe (the reboot happens under the singleton flock).

Also harden teardown: the PID file drifts after crashes / out-of-band kills,
so `stop` now also reaps any orphaned firecracker still bound to the infra
config path (scoped to that path, so interactive-pool VMs are untouched) —
otherwise a survivor holds the orchestrator TAP and the fresh boot dies with
"tap … Resource busy".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9qa3xoavjQScufDfZaXKR
2026-07-19 01:03:30 -04:00
didericis 6f885af4b4 fix(firecracker): make guest /tmp world-writable so agents can use it
test / integration-docker (pull_request) Successful in 16s
test / coverage (pull_request) Failing after 36s
test / integration-firecracker (pull_request) Failing after 43s
lint / lint (push) Failing after 56s
test / unit (pull_request) Successful in 1m37s
tracker-policy-pr / check-pr (pull_request) Failing after 13m24s
The rootless agent rootfs build can land /tmp as 0755/root-owned, so the
agent (uid 1000 node) can't create scratch dirs there. The sandbox-escape
suite's README-push test does `cd /tmp && git init sandbox-escape-repo` and
died with "cannot mkdir sandbox-escape-repo: Permission denied" — before the
git-gate gitleaks hook could run — so the test read it as a missing hook.
On docker the agent inherits node:22-slim's 1777 /tmp, which is why only the
Firecracker path was affected (and only now that the suite runs end-to-end).

Set /tmp to 1777 in the guest PID-1 init, so every agent VM boots with a
usable /tmp regardless of rootfs perm drift.

Also update the infra-init unit test to assert the gateway launches via the
`bot_bottle.gateway_init` module (matching the prior fix), not a file path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9qa3xoavjQScufDfZaXKR
2026-07-19 00:49:00 -04:00
didericis 127ba49372 fix(firecracker): launch infra-VM gateway as a module, not /app/gateway_init.py
test / integration-docker (pull_request) Successful in 18s
tracker-policy-pr / check-pr (pull_request) Successful in 19s
test / unit (pull_request) Failing after 35s
lint / lint (push) Failing after 46s
test / coverage (pull_request) Failing after 1m37s
test / integration-firecracker (pull_request) Failing after 1m50s
The infra VM's init boots the orchestrator control plane AND the gateway data
plane (egress/git-http/supervise). Since 5ad3449 moved the daemons into the
installed `bot_bottle` package, there is no `/app/gateway_init.py` file — the
gateway image's entrypoint is `python3 -m bot_bottle.gateway_init`. But the
firecracker init still spawned the old file path, so the guest logged:

    python3: can't open file '/app/gateway_init.py': No such file or directory

The control plane came up (it already used `python3 -m bot_bottle.orchestrator`)
but the gateway never started, so mitmproxy never generated its CA and launch
died with "gateway CA not available after 30s". Mirror the orchestrator line:
run the gateway as `python3 -m bot_bottle.gateway_init` (bot_bottle resolves
from the /app CWD, same as the control plane; egress-entrypoint.sh /
egress_addon.py are present from the gateway base image).

Third build/runtime bug from 5ad3449's package refactor that the Firecracker
integration suite never exercised (the artifact publish was broken, so the
job 404'd before boot). Changes the init, so the infra artifact version moves;
the matching rootfs has been rebuilt and published.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9qa3xoavjQScufDfZaXKR
2026-07-19 00:35:19 -04:00
didericis 0d696674e3 fix(infra-build): repair gateway image build so infra artifact can publish
tracker-policy-pr / check-pr (pull_request) Successful in 16s
test / integration-docker (pull_request) Successful in 31s
test / unit (pull_request) Successful in 40s
test / integration-firecracker (pull_request) Failing after 2m36s
test / coverage (pull_request) Failing after 3m34s
The Firecracker integration + coverage jobs pull a prebuilt infra rootfs
artifact (PRD 0069 Stage 2) versioned by a content hash of the Dockerfiles,
bot_bottle/, and the guest init. Building that artifact (publish_infra ->
docker build Dockerfile.gateway) has been broken since 5ad3449, so the
artifact was never published and the KVM runner's integration test 404'd on
the pull — the failure this branch surfaced once it stopped falsely skipping.

Two build-time bugs, both from 5ad3449, neither exercised since:

- pyproject.toml declared build-backend "setuptools.backends.legacy:build",
  which is not an importable module; `pip install /src/` failed with
  BackendUnavailable. Use the real backend, "setuptools.build_meta"
  (the project has proper [project] metadata + flat-layout autodiscovery).
  Not part of the artifact hash, so this alone doesn't move the version.

- Dockerfile.gateway wrote /app/egress_addon.py before /app existed (the
  mkdir/WORKDIR came later), so the RUN redirect died with exit 2. Move
  WORKDIR /app above the shim write (WORKDIR creates it) and drop the now
  redundant later WORKDIR. This changes the gateway Dockerfile, so the infra
  artifact version moves 3c9e7b23260992db -> 01e6aaa714756fce; the matching
  artifact has been built and published to the generic package registry.

Also add Dockerfile* and pyproject.toml to test.yml's path filters: these
inputs determine what the firecracker jobs build/pull, so a change to them
must re-run the suite (and lets this push trigger a pull_request run).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9qa3xoavjQScufDfZaXKR
2026-07-18 22:59:49 -04:00
didericis-codex 626f07efa6 fix(tests): run sandbox integration on firecracker
test / integration-firecracker (pull_request) Failing after 5s
test / integration-docker (pull_request) Successful in 9s
tracker-policy-pr / check-pr (pull_request) Successful in 7s
test / unit (pull_request) Successful in 33s
test / coverage (pull_request) Failing after 27s
lint / lint (push) Failing after 42s
2026-07-19 02:34:53 +00:00
didericis-claude d117460192 test: cover _daemon_reachable timeout path and DbStore.is_migrated
test / integration-firecracker (pull_request) Successful in 13s
tracker-policy-pr / check-pr (pull_request) Successful in 11s
test / integration-docker (pull_request) Successful in 14s
test / coverage (pull_request) Successful in 34s
lint / lint (push) Successful in 50s
test / unit (pull_request) Successful in 1m31s
Two diff-coverage gaps on the ci-kvm-runner branch:

1. bot_bottle/backend/docker/setup.py: the try/except TimeoutExpired
   block added in a prior commit had no tests reaching the subprocess
   path. Add two tests to TestDockerSetupStatus: one for the success
   path (subprocess returns 0) and one for the TimeoutExpired fallback.

2. bot_bottle/db_store.py: the _connection() context manager change in
   is_migrated() was never exercised by unit tests (all callers mock
   is_migrated() directly). Add test_db_store.py covering the absent-DB,
   missing-schema-table, migrated, and behind-schema cases.
2026-07-19 02:18:07 +00:00
didericis-claude e72ec71047 fix(tests): mock name_color_modal in test_cli_start_selector setUp
On a self-hosted KVM runner the process has a real controlling terminal
so name_color_modal successfully opens /dev/tty and enters a curses
loop waiting for keyboard input, hanging the test indefinitely.

Docker containers (ubuntu-latest runners) don't have a real /dev/tty,
causing an OSError that triggers the existing fallback — this is why
the hang was invisible in ubuntu-latest CI.

Also add timeout=5 to _daemon_reachable() to match the same defensive
fix already applied to docker_available() in tests/_docker.py.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-19 02:16:10 +00:00
didericis-claude 7aff69fbe0 fix(coverage): skip docker integration tests on the KVM runner
Docker integration tests are already covered by the integration-docker
job on ubuntu-latest. On the KVM runner the Firecracker TAP/nftables
pool conflicts with Docker networking, causing those tests to hang
and the coverage job to never complete.

Add SKIP_DOCKER_TESTS env-var support to docker_available() and set
it for the integration phase of coverage.sh so only Firecracker
integration tests run there.
2026-07-19 02:16:10 +00:00
didericis-claude 1d91db3e31 ci: fix tracker-policy-pr trigger — synchronize not synchronized
Gitea fires the pull_request push event as 'synchronize' (GitHub spec),
not 'synchronized'. The typo meant the workflow only ran on opened/
edited/reopened, leaving the required check yellow with no details link
after every commit push.
2026-07-19 02:16:10 +00:00
didericis-claude 686ca0d74b fix(tests): add 5-second timeout to docker_available() to prevent hang on KVM runner
On the self-hosted KVM runner Docker is on PATH but the daemon socket
is unreachable (firewalled/dropped). subprocess.run(["docker", "info"])
with no timeout hangs indefinitely on a dropped connection, stalling the
coverage job for hours — one hang per @skip_unless_docker()-decorated
class, ~8 per integration suite run.

Add timeout=5 with a TimeoutExpired → False fallback so the check
resolves quickly to "unreachable" rather than blocking.
2026-07-19 02:16:10 +00:00
didericis-codex 6d44a1be0a ci: scope firecracker backend to integration coverage 2026-07-19 02:16:10 +00:00
didericis-claude 32e85de16f fix(db): close SQLite connections explicitly to suppress ResourceWarning on Python 3.13
`sqlite3.Connection.__exit__` only commits/rolls back a transaction — it
does not close the connection. Python 3.13 (the Nix env on the KVM
runner) emits `ResourceWarning: unclosed database` for every connection
GC'd without an explicit close, producing noisy output in the coverage job.

Add `DbStore._connection()`, a `contextmanager` that calls `self._connect()`,
wraps it in the existing transaction context manager, and closes the
connection in a `finally` block. Change all `with self._connect() as conn:`
call sites in `db_store.py`, `audit_store.py`, `queue_store.py`, and
`orchestrator/registry.py` to `with self._connection() as conn:`.
`_connect()` remains as the per-subclass hook (RegistryStore overrides
it to set `busy_timeout`); `_connection()` delegates to `self._connect()` so
the override is respected.
2026-07-19 02:16:10 +00:00
didericis a1d2c4a500 ci: drop dev-requirements pip install on the self-hosted KVM runner
The self-hosted runner's Nix python env has no `pip` module, so
`python3 -m pip install -r requirements-dev.txt` failed with "No module
named pip" in both firecracker jobs. Neither job needs that install:

- integration-firecracker runs the stdlib `unittest` suite (no deps);
- coverage needs only `coverage`, which the runner's Nix python env
  already ships (7.12.0) — verified `coverage run`/`coverage json` work.

pylint/pyright are lint.yml's concern, not test.yml's. The ubuntu-latest
`unit` job keeps its `--break-system-packages` install unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S1qRZTJC6qgBsUSjNrBdkX
2026-07-19 02:16:10 +00:00
didericis-claude a6fe31a424 ci(test): split integration into per-backend jobs
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-19 02:16:10 +00:00
didericis-claude 41b2b24b36 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-19 02:16:10 +00:00
didericis-claude 37045ca147 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-19 02:16:10 +00:00
didericis-claude 9b54cfa854 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-19 02:16:10 +00:00
didericis c193b04338 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-19 02:16:10 +00:00
didericis c7ab3e0957 fix(tests): resolve sleep from PATH so subprocess tests run on NixOS
test / integration (push) Successful in 8s
test / unit (push) Successful in 33s
lint / lint (push) Successful in 44s
test / coverage (push) Successful in 36s
Update Quality Badges / update-badges (push) Successful in 34s
NixOS doesn't populate /bin (no /bin/sleep), so the gateway-init
end-to-end tests that spawn a real `sleep` errored with
FileNotFoundError. Add tests/_bin.py with a PATH-resolved SLEEP
constant (falling back to /bin/sleep on FHS hosts) and import it in
test_gateway_init.py instead of hardcoding the path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9qa3xoavjQScufDfZaXKR
2026-07-18 22:09:05 -04:00
didericis 034f774529 feat(supervise): non-blocking MCP — pending carries proposal id + check-proposal poll tool
test / integration (pull_request) Successful in 10s
tracker-policy-pr / check-pr (pull_request) Successful in 11s
test / coverage (pull_request) Successful in 39s
test / unit (pull_request) Successful in 1m30s
prd-number / assign-numbers (push) Failing after 10s
test / integration (push) Successful in 7s
test / unit (push) Successful in 30s
lint / lint (push) Successful in 42s
test / coverage (push) Successful in 35s
Update Quality Badges / update-badges (push) Successful in 34s
Closes #412.

The supervise MCP server blocked the agent's tool call polling for the
operator's decision, and on timeout returned `status: pending` with no
proposal id and no way to poll a specific proposal — so the only way to
learn a late decision was to re-propose (a duplicate).

- `handle_tools_call` pending timeout now returns the `proposal_id` and
  points the agent at `check-proposal`.
- New `check-proposal` MCP tool: non-blocking status lookup by proposal id
  (pending | approved | modified | rejected | unknown). Reuses the queue's
  FileNotFoundError semantics; archives a decided proposal exactly like the
  synchronous path, so a pending proposal stays visible to the operator
  until it's both decided and polled.
- `TOOL_CHECK_PROPOSAL` constant, re-exported from supervise; kept out of
  TOOLS since it never becomes a Proposal.tool.

Enforcement is unchanged — the tools only propose policy; the egress proxy
and git-gate still enforce — so returning early opens no hole. Follow-ups
(git-gate reject-requeue, backpressure, notifications, web console) are in
the PRD.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YBCHap11yGAKuKfsehNPaD
2026-07-18 17:06:04 -04:00
didericis 5b359fe8d2 fix(git-gate): scan $new --not --all for every push, not $old..$new
test / unit (push) Successful in 32s
test / integration (push) Successful in 31s
lint / lint (push) Successful in 43s
Update Quality Badges / update-badges (push) Successful in 38s
test / coverage (push) Successful in 40s
The pre-receive hook scanned existing-branch updates with the delta range
$old..$new. On a rebase / non-fast-forward force-push onto an advanced main,
$old is no longer an ancestor of $new, so $old..$new expands to all of main's
new history — including the deliberate sandbox-escape gitleaks fixtures — and
the push is rejected on commits that belong to main, not the branch.

Unify the range on `$new --not --all` for every non-delete push (this is the
deferred open question from PRD 0028, which already applied it to new refs
for #106). It scans only the commits the push introduces and is
security-equivalent: the bare repo's refs come only from trusted upstream
mirror-fetch and gitleaks-gated pushes, so an excluded commit is
already-upstream or already-scanned. It is also more correct for
non-fast-forward pushes, where $old..$new can skip commits off the direct path.

Fixes #421

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S1qRZTJC6qgBsUSjNrBdkX
2026-07-18 16:51:31 -04:00
didericis 015ff52eda fix(cli): exempt backend command from DB migration gate
test / integration (pull_request) Successful in 8s
tracker-policy-pr / check-pr (pull_request) Successful in 9s
test / unit (pull_request) Successful in 31s
test / coverage (pull_request) Successful in 36s
test / integration (push) Successful in 12s
test / unit (push) Successful in 36s
test / coverage (push) Successful in 39s
Update Quality Badges / update-badges (push) Successful in 37s
lint / lint (push) Successful in 2m38s
`backend setup/status/teardown` manage host prerequisites only and never
open the store, but the CLI dispatcher ran the schema-migration gate before
every command. On a non-TTY runner the gate's `Migrate now? [y/N]` prompt
reads EOF and refuses, so `backend status --backend=firecracker` exits 1 —
breaking the Firecracker CI preflight on any host without a pre-migrated DB.

Exempt `backend` from the gate; store-touching commands stay gated.

Fixes #419

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S1qRZTJC6qgBsUSjNrBdkX
2026-07-18 15:27:29 -04:00
didericis-claude 4302678f3e docs(research): expand sandbox landscape with 6 new tools; add agent-tailored policy axis
tracker-policy-pr / check-pr (pull_request) Failing after 6s
Isolation tools added: Cleanroom (Buildkite), container-use (Dagger),
Docker sbx, Anthropic srt.

Governance/pre-action layers added as a separate section: Microsoft
Agent Governance Toolkit (per-agent DID + YAML policy + trust score),
Open Agent Passport (declarative policy + cryptographic audit).

Comparison table: 14 → 14 columns; new Agent-tailored policy row added.
Second addendum covers competitive position on role-tailoring, Docker
sbx as new DX-class competitor, and borrowable ideas (trust-score decay,
live network TUI, cryptographic audit chain).

Discourse note: adds Per-agent role tailoring to "What it covers well"
with competitive comparison table across 9 tools.
2026-07-18 19:11:14 +00:00
didericis-claude 3a6fbad057 ci: split tracker-policy into separate issue and PR workflows
tracker-policy-pr / check-pr (pull_request) Successful in 4s
Gitea Actions reports skipped jobs as a non-success status, which caused
label-issue to block PRs even though its if-condition correctly excluded it.
Two dedicated workflows eliminate the skipped-job problem entirely.

After merge, update the branch-protection required status context from
`tracker-policy / check-pr` to `tracker-policy-pr / check-pr`.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-18 18:37:44 +00:00
didericis-codex a800a417d9 ci: enforce pylint score instead of warning exit bits
test / integration (pull_request) Successful in 9s
test / coverage (pull_request) Successful in 39s
test / unit (pull_request) Successful in 1m22s
test / integration (push) Successful in 25s
test / unit (push) Successful in 34s
lint / lint (push) Successful in 42s
Update Quality Badges / update-badges (push) Successful in 39s
test / coverage (push) Successful in 42s
2026-07-18 14:18:39 -04:00
didericis-codex 293218035d ci: enforce canonical issue metadata policy 2026-07-18 14:18:39 -04:00
didericis-claude 727eafe0f9 docs(research): clarify prompt injection framing and blast-radius risks
Collapse "trusted-channel data injection" into prompt injection
throughout — the trusted channel is a delivery vector, not a distinct
attack class. Add explicit inbound/outbound orthogonality framing.
Replace the two redundant "weaker" bullets with a single prompt
injection section and a new blast-radius breakdown covering work
product corruption, malicious commits past gitleaks, exfiltration
through allowlisted channels, and dependency-install injection.
2026-07-18 18:13:33 +00:00
didericis-claude 1ec114b6d7 docs(research): survey HN agent safety discourse June-July 2026
Covers the CVE cascade (DuneSlide, CVE-2026-39861, MCP STDIO injection),
Agentjacking and README-injection attack classes, community opinion
clusters, and a frank assessment of where bot-bottle covers or falls
short against each issue.
2026-07-18 18:08:40 +00:00
didericis aa44feea02 docs(research): note on malicious-commit scanning at the git-gate + paid-feature analysis
Adds a research note on whether/how to scan for malicious code (not just
secrets) in commits pushed through the git-gate, and whether the semantic
(LLM) layer is a defensible paid feature.

Verdict: no scanner reliably detects malicious code (undecidable +
adversarial), so the frame is raise-cost + cover-the-obvious + human-gate
the dangerous. Ranked layers: dependency/supply-chain scanning (Socket/OSV/
GuardDog) > heuristic/obfuscation (Semgrep-on-diff) > risk-based human
gating via the existing supervise plane > best-effort LLM diff-review.
Fast scanners inline in the synchronous pre-receive; heavy analysis async.

Monetization: the paid unit is the governed git-egress review bundle
(managed semantic review + web-console human-review flow + RBAC + audit +
cross-run policy), not the raw scanner — which stays OSS like gitleaks.
Extends the egress audit+custody wedge to code artifacts; the supervise
console generalizes across all proposal types (egress, gitleaks, commit
review). Sell the workflow, not the detector's accuracy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YBCHap11yGAKuKfsehNPaD
2026-07-18 07:00:22 -04:00
didericis f2e2572a40 docs(research): add DX axis — "run Claude yolo-style" — to the sandbox landscape
Adds a "DX: run Claude yolo-style" row to the comparison table plus a note
framing developer experience as a differentiator. The field splits into
wrappers-around-the-agent (bot-bottle, agent-safehouse — one command, the
agent just runs, `--dangerously-skip-permissions` on by default with the
sandbox as the guardrail) vs libraries/services (boxlite, microsandbox,
CubeSandbox, E2B — you wire the agent in via SDK/cluster). agent-safehouse
is the only DX peer, but it's macOS-only Seatbelt with no egress story.
"As easy as native yolo, but actually sandboxed" is the defensible line.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YBCHap11yGAKuKfsehNPaD
2026-07-18 06:41:26 -04:00
didericis 7069fa225d docs(research): add long-running posture axis to the sandbox landscape
Adds a "Long-running posture" row to the comparison table and an addendum
note contrasting the two models: E2B and CubeSandbox are ephemeral-per-task
(5-min default timeout, tier-capped continuous runtime, duration via
pause/resume + reconnect-by-id), while bot-bottle bottles are persistent,
named, and supervised by default. For agents that run for hours/days this
posture difference matters more than the isolation primitive.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YBCHap11yGAKuKfsehNPaD
2026-07-18 06:28:26 -04:00
didericis aa224c4381 docs(research): add CubeSandbox to the sandbox landscape; fix stale bot-bottle self-description
Adds CubeSandbox (Tencent Cloud, Apache 2.0, RustVMM/KVM microVM) to the
agent-sandbox landscape survey: per-project note, comparison-table column,
and a dated addendum on what it means for positioning. CubeSandbox is the
first surveyed project to bundle a connection-level egress allowlist +
audit + in-flight credential custody, but it does NOT do content DLP on
authorized channels — that plus the orchestration layer is where
bot-bottle stays distinctive.

Also corrects two stale self-descriptions the survey (2026-05-11) baked
in and I'd propagated:
- Default isolation is now a VM per bottle (Firecracker microVM on KVM
  Linux, Apple Container on macOS); Docker is only the legacy fallback,
  per _default_backend_name(). Was described as Docker-by-default.
- Outbound DLP is bot-bottle's own mitmproxy egress scanner + gitleaks on
  git push, not pipelock (removed). All references updated; a note
  records the change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YBCHap11yGAKuKfsehNPaD
2026-07-18 06:28:12 -04:00
53 changed files with 2394 additions and 777 deletions
+13 -2
View File
@@ -24,8 +24,19 @@ jobs:
- name: Run pylint - name: Run pylint
run: | run: |
# Run pylint on all Python files in the repo # Pylint's normal exit code is nonzero for any emitted finding,
find . -name '*.py' -not -path './.venv/*' -not -path './.git/*' | xargs pylint --fail-under=8.0 # regardless of --fail-under. Preserve the full report but enforce
# the aggregate score this workflow promises.
set +e
find . -name '*.py' -not -path './.venv/*' -not -path './.git/*' \
| xargs pylint --fail-under=8.0 \
| tee /tmp/pylint-output.txt
set -e
SCORE=$(sed -n \
's/^Your code has been rated at \([-0-9.]*\)\/10.*/\1/p' \
/tmp/pylint-output.txt | tail -1)
test -n "$SCORE"
awk -v score="$SCORE" 'BEGIN { exit !(score >= 8.0) }'
- name: Run pyright - name: Run pyright
run: | run: |
+103 -26
View File
@@ -4,16 +4,15 @@
# dependencies are required to execute it. Tests are split by directory: # dependencies are required to execute it. Tests are split by directory:
# #
# tests/unit/ — pure unit tests; always run # tests/unit/ — pure unit tests; always run
# tests/integration/ — need a reachable Docker daemon; skip cleanly # tests/integration/ — need a reachable backend; skip cleanly when
# (via tests/_docker.py:skip_unless_docker) when # the backend isn't available on the runner
# Docker isn't available on the runner
# tests/canaries/ — upstream regression canaries; run on a separate # tests/canaries/ — upstream regression canaries; run on a separate
# schedule (see canaries.yml), not here # schedule (see canaries.yml), not here
# #
# This workflow assumes the Gitea Actions runner exposes the host Docker # Integration tests run once per backend in separate jobs. Each job sets
# socket to the job container so `docker` commands inside the job can # BOT_BOTTLE_BACKEND explicitly so the test suite uses the right backend.
# reach the daemon. If that's not yet configured on the runner the # Backends that aren't available on the runner fail the preflight step
# integration tests will skip rather than fail. # rather than silently skipping inside the test output.
name: test name: test
@@ -23,9 +22,24 @@ on:
- main - main
paths: paths:
- '**.py' - '**.py'
- '.gitea/workflows/**.yml'
- 'scripts/**'
- 'README.md'
# The Firecracker infra artifact the integration/coverage jobs pull is
# versioned by a content hash of the Dockerfiles (+ bot_bottle/, init);
# pyproject.toml drives the in-image package install. Changes here alter
# what those jobs build/pull, so they must re-run the suite.
- 'Dockerfile*'
- 'pyproject.toml'
pull_request: pull_request:
paths: paths:
- '**.py' - '**.py'
- '.gitea/workflows/**.yml'
- 'scripts/**'
- 'README.md'
- 'Dockerfile*'
- 'pyproject.toml'
workflow_dispatch:
jobs: jobs:
unit: unit:
@@ -48,7 +62,7 @@ jobs:
- name: Report unit coverage - name: Report unit coverage
run: python3 -m coverage report -m run: python3 -m coverage report -m
integration: integration-docker:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
@@ -65,33 +79,96 @@ jobs:
echo "docker not on PATH — integration tests will skip" echo "docker not on PATH — integration tests will skip"
fi 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 run: python3 -m unittest discover -t . -s tests/integration -v
# Combined unit+integration coverage report (informational). See # Integration tests against the Firecracker backend. Runs on a self-hosted
# docs/decisions/0004-coverage-policy.md. # 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 # Restricted to same-repo PRs, push to main, and workflow_dispatch — fork
# Firecracker backend's VM/SSH orchestration is covered by the integration # PRs don't execute untrusted code on the privileged runner.
# suite, which needs /dev/kvm + the provisioned TAP/nft pool — a #
# container-based runner skips it and those lines read uncovered, so the # Runner prerequisites (provision once; see README "Firecracker on Linux"):
# gate can't pass here. Re-enabling it on a self-hosted KVM runner is # `firecracker` on PATH, `/dev/kvm` accessible, Docker, cached kernel +
# tracked separately (see PRD 0069 / #348 and the ci-runner branch). # 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
# No dev-requirements install: the integration suite runs on stdlib
# `unittest` (pylint/pyright are lint.yml's concern, not this job's),
# and the self-hosted runner's Nix python env has no `pip` module
# (`python3 -m pip` → "No module named pip"). Nothing to install.
- 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: coverage:
runs-on: ubuntu-latest timeout-minutes: 15
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: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
# No actions/setup-python: the runner image already ships Python 3.12, - name: Preflight — Firecracker host is ready
# and older act_runner engines mishandle setup-python's PATH (coverage run: |
# lands in one interpreter, `python3` resolves to another). Install command -v firecracker >/dev/null || {
# straight into the ephemeral job container's system Python — echo "firecracker not on PATH — provision the runner (README: Firecracker on Linux)"; exit 1; }
# --break-system-packages is safe because the container is disposable. test -e /dev/kvm || { echo "/dev/kvm missing — KVM not available on this runner"; exit 1; }
- name: Install dev requirements # `backend status` exits non-zero unless the TAP pool is up + no
run: python3 -m pip install --break-system-packages -r requirements-dev.txt # range overlap; it prints the exact `backend setup` fix.
python3 cli.py backend status --backend=firecracker
- name: Combined coverage report (unit + integration) # No dev-requirements install: `coverage` is already provided by the
# self-hosted runner's Nix python env, and that env has no `pip`
# module to install into anyway. `scripts/coverage.sh` +
# `diff_coverage.py` need only `coverage` (not pylint/pyright).
- name: Combined coverage (unit + integration, incl. firecracker)
run: PYTHON=python3 bash scripts/coverage.sh critical 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
@@ -0,0 +1,17 @@
name: tracker-policy-issues
on:
issues:
types: [opened, unlabeled]
jobs:
label-issue:
runs-on: ubuntu-latest
permissions:
issues: write
steps:
- uses: actions/checkout@v4
- name: Ensure the issue has a label
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: python3 scripts/tracker_policy.py label-issue
+18
View File
@@ -0,0 +1,18 @@
name: tracker-policy-pr
on:
pull_request:
types: [opened, edited, reopened, synchronize, labeled, unlabeled]
jobs:
check-pr:
runs-on: ubuntu-latest
permissions:
issues: read
pull-requests: read
steps:
- uses: actions/checkout@v4
- name: Require an unlabeled PR linked to an issue
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: python3 scripts/tracker_policy.py check-pr
+3 -2
View File
@@ -98,6 +98,9 @@ RUN pip install --no-cache-dir /src/
# mitmdump -s requires a file path, not a module. Write a one-line shim that # mitmdump -s requires a file path, not a module. Write a one-line shim that
# re-exports `addons` from the installed package; mitmdump finds it there. # re-exports `addons` from the installed package; mitmdump finds it there.
# WORKDIR here also creates /app so the shim + COPYs below can write into it
# (nothing created /app before this point).
WORKDIR /app
RUN printf 'from bot_bottle.egress_addon import addons\n' > /app/egress_addon.py RUN printf 'from bot_bottle.egress_addon import addons\n' > /app/egress_addon.py
COPY bot_bottle/egress_entrypoint.sh /app/egress-entrypoint.sh COPY bot_bottle/egress_entrypoint.sh /app/egress-entrypoint.sh
RUN chmod +x /app/egress-entrypoint.sh RUN chmod +x /app/egress-entrypoint.sh
@@ -117,8 +120,6 @@ RUN mkdir -p \
# subset the bottle uses. # subset the bottle uses.
EXPOSE 8888 9099 9418 9420 9100 EXPOSE 8888 9099 9418 9420 9100
WORKDIR /app
# PID 1 is the supervisor. It owns signal handling and exit-code # PID 1 is the supervisor. It owns signal handling and exit-code
# propagation; no `exec` chain in the entrypoint itself. # propagation; no `exec` chain in the entrypoint itself.
ENTRYPOINT ["python3", "-m", "bot_bottle.gateway_init"] ENTRYPOINT ["python3", "-m", "bot_bottle.gateway_init"]
+11
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`. > **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 ```sh
./cli.py start <agent> # builds the image on first run, drops you into claude ./cli.py start <agent> # builds the image on first run, drops you into claude
``` ```
@@ -171,6 +173,15 @@ When an outbound DLP detector matches a token, the route's `dlp.outbound_on_matc
More examples in `examples/`. Full design lives under `docs/prds/`; the trust-boundary rationale is in `docs/prds/0011-per-file-md-manifest.md`. More examples in `examples/`. Full design lives under `docs/prds/`; the trust-boundary rationale is in `docs/prds/0011-per-file-md-manifest.md`.
## Tracker policy
Issues are the canonical work items and own all tracker labels; every issue
must have at least one. Pull requests stay unlabeled and deliberately reference
an issue with `Closes #…`, `Part of #…`, or another form defined in
[`ADR 0005`](docs/decisions/0005-issues-own-tracker-metadata.md). Gitea Actions
enforces the convention for new work from 2026-07-18 onward. Earlier closed
PRs are grandfathered rather than given artificial retrospective issues.
## Trademarks ## Trademarks
bot-bottle is an independent project and is not affiliated with, endorsed by, or sponsored by Anthropic, PBC. "Claude" and "Claude Code" are trademarks of Anthropic, PBC; the project name uses "claude" descriptively to indicate that the tool runs Claude Code inside a sandbox. bot-bottle is an independent project and is not affiliated with, endorsed by, or sponsored by Anthropic, PBC. "Claude" and "Claude Code" are trademarks of Anthropic, PBC; the project name uses "claude" descriptively to indicate that the tool runs Claude Code inside a sandbox.
-4
View File
@@ -45,10 +45,6 @@ PROVIDER_TEMPLATES = frozenset({PROVIDER_CLAUDE, PROVIDER_CODEX, PROVIDER_PI})
# forward_host_credentials is enabled. Pipelock must pass these through # forward_host_credentials is enabled. Pipelock must pass these through
# (no TLS MITM) or its header DLP blocks the injected JWT. # (no TLS MITM) or its header DLP blocks the injected JWT.
CODEX_HOST_CREDENTIAL_HOSTS = ("api.openai.com", "chatgpt.com") CODEX_HOST_CREDENTIAL_HOSTS = ("api.openai.com", "chatgpt.com")
# Host that egress injects the host Claude bearer on when Claude
# forward_host_credentials is enabled.
CLAUDE_HOST_CREDENTIAL_HOSTS = ("api.anthropic.com",)
PromptMode = Literal[ PromptMode = Literal[
"append_file", "append_file",
"read_prompt_file", "read_prompt_file",
+2 -2
View File
@@ -42,7 +42,7 @@ class AuditStore(DbStore):
super().__init__(db_path or host_db_path(), migrations) super().__init__(db_path or host_db_path(), migrations)
def write_audit_entry(self, entry: AuditEntry) -> Path: def write_audit_entry(self, entry: AuditEntry) -> Path:
with self._connect() as conn: with self._connection() as conn:
conn.execute( conn.execute(
""" """
INSERT INTO supervise_audit_entries ( INSERT INTO supervise_audit_entries (
@@ -66,7 +66,7 @@ class AuditStore(DbStore):
def read_audit_entries(self, component: str, slug: str) -> list[AuditEntry]: def read_audit_entries(self, component: str, slug: str) -> list[AuditEntry]:
if not self.db_path.is_file(): if not self.db_path.is_file():
return [] return []
with self._connect() as conn: with self._connection() as conn:
rows = conn.execute( rows = conn.execute(
""" """
SELECT * FROM supervise_audit_entries SELECT * FROM supervise_audit_entries
+5 -1
View File
@@ -27,10 +27,14 @@ def _docker_on_path() -> bool:
def _daemon_reachable() -> bool: def _daemon_reachable() -> bool:
if not _docker_on_path(): if not _docker_on_path():
return False return False
try:
return subprocess.run( return subprocess.run(
["docker", "info"], ["docker", "info"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
check=False, timeout=5,
).returncode == 0 ).returncode == 0
except subprocess.TimeoutExpired:
return False
def _print_install_pointer() -> None: def _print_install_pointer() -> None:
@@ -38,25 +38,39 @@ _BUILD_TIMEOUT_SECONDS = 900.0
def _dockerfile_hash(dockerfile: Path) -> str: def _dockerfile_hash(dockerfile: Path) -> str:
"""Cache key: the Dockerfile's content. The shipped agent Dockerfiles """The Dockerfile's content hash. The shipped agent Dockerfiles COPY
COPY nothing from the build context (see .dockerignore), so their content nothing from the build context (see .dockerignore), so their content fully
fully determines the image; a Dockerfile that adds COPY will want the determines the built image; a Dockerfile that adds COPY will want the
context folded in here too.""" context folded in here too."""
return hashlib.sha256(dockerfile.read_bytes()).hexdigest()[:16] return hashlib.sha256(dockerfile.read_bytes()).hexdigest()[:16]
def _rootfs_digest(dockerfile: Path) -> str:
"""Cache key for the built AND boot-injected agent rootfs. Two inputs
determine the on-disk rootfs: the Dockerfile (the image) and the guest init
injected into it (`util._GUEST_INIT`). Folding the init in means a fix to
it e.g. making /tmp world-writable busts the cache instead of silently
reusing a stale rootfs built with the old init."""
h = hashlib.sha256()
h.update(_dockerfile_hash(dockerfile).encode())
h.update(b"\0")
h.update(util._GUEST_INIT.encode())
return h.hexdigest()[:16]
def build_agent_rootfs_dir( def build_agent_rootfs_dir(
dockerfile: Path, *, image_tag: str, smoke_test: tuple[str, ...] = (), dockerfile: Path, *, image_tag: str, smoke_test: tuple[str, ...] = (),
) -> Path: ) -> Path:
"""Build `dockerfile` in the infra VM (buildah, no host docker), export its """Build `dockerfile` in the infra VM (buildah, no host docker), export its
rootfs, inject the guest boot bits, and return the cached base dir the rootfs, inject the guest boot bits, and return the cached base dir the
same shape `util.build_rootfs_ext4` consumes. Cached by Dockerfile content, same shape `util.build_rootfs_ext4` consumes. Cached by Dockerfile content
so a repeat launch skips the rebuild. + injected guest init, so a repeat launch skips the rebuild but an init or
Dockerfile change rebuilds.
`smoke_test` (the provider's declared argv, e.g. `("claude","--version")`) `smoke_test` (the provider's declared argv, e.g. `("claude","--version")`)
is run in the freshly built image before export, catching an npm is run in the freshly built image before export, catching an npm
silent-failure image at build time rather than at first agent use.""" silent-failure image at build time rather than at first agent use."""
digest = _dockerfile_hash(dockerfile) digest = _rootfs_digest(dockerfile)
base = util.cache_dir() / "rootfs" / f"agent-{digest}" base = util.cache_dir() / "rootfs" / f"agent-{digest}"
if (base / ".bb-ready").is_file(): if (base / ".bb-ready").is_file():
info(f"using cached agent rootfs {base.name}") info(f"using cached agent rootfs {base.name}")
+66 -5
View File
@@ -161,20 +161,23 @@ def ensure_running() -> InfraVm:
slot = netpool.orch_slot() slot = netpool.orch_slot()
url = f"http://{slot.guest_ip}:{CONTROL_PLANE_PORT}" url = f"http://{slot.guest_ip}:{CONTROL_PLANE_PORT}"
key = _infra_dir() / "id_ed25519" key = _infra_dir() / "id_ed25519"
if key.exists() and _health_ok(url): want = _expected_version()
if _adoptable(key, url, want):
info(f"adopting running infra VM at {url}") info(f"adopting running infra VM at {url}")
return InfraVm(guest_ip=slot.guest_ip, private_key=key) return InfraVm(guest_ip=slot.guest_ip, private_key=key)
with _singleton_lock(): with _singleton_lock():
# Re-check under the lock: another launcher may have booted it while # Re-check under the lock: another launcher may have booted it while
# we waited for the lock (double-checked, so we adopt not re-boot). # we waited for the lock (double-checked, so we adopt not re-boot).
if key.exists() and _health_ok(url): if _adoptable(key, url, want):
info(f"adopting running infra VM at {url}") info(f"adopting running infra VM at {url}")
return InfraVm(guest_ip=slot.guest_ip, private_key=key) return InfraVm(guest_ip=slot.guest_ip, private_key=key)
stop() # clear a stale/hung VM holding the link before booting fresh # Clear a stale/hung/OUTDATED VM holding the link before booting fresh.
stop()
ensure_built() ensure_built()
infra = boot() infra = boot()
wait_for_health(infra) wait_for_health(infra)
_record_booted_version(want)
return infra return infra
@@ -193,9 +196,15 @@ def _singleton_lock() -> Generator[None, None, None]:
def stop() -> None: def stop() -> None:
"""Stop the infra VM singleton (idempotent — absent is success).""" """Stop the infra VM singleton (idempotent — absent is success). Reaps the
recorded VMM AND any orphaned firecracker still bound to the infra config
the PID file drifts after crashes / out-of-band kills, and a survivor would
hold the orchestrator TAP so the next boot dies with "tap … Resource busy".
Drops the version marker so a stopped VM is never treated as adoptable."""
_kill_pidfile() _kill_pidfile()
_kill_infra_firecrackers()
_pid_file().unlink(missing_ok=True) _pid_file().unlink(missing_ok=True)
_version_file().unlink(missing_ok=True)
def boot() -> InfraVm: def boot() -> InfraVm:
@@ -238,6 +247,36 @@ def _pid_file() -> Path:
return _infra_dir() / "vm.pid" return _infra_dir() / "vm.pid"
def _version_file() -> Path:
"""Records the infra-artifact version the *running* VM booted from, so a
later launcher can tell whether the singleton it found is the current code.
Without it, a healthy VM built from an older image gets adopted forever and
the new code never boots every infra change would need an out-of-band
kill to dislodge the stale VM (and races whatever launched next)."""
return _infra_dir() / "booted-version"
def _expected_version() -> str:
return infra_artifact.infra_artifact_version(_infra_init())
def _adoptable(key: Path, url: str, want: str) -> bool:
"""Adopt a running infra VM only if it booted from the CURRENT version and
its control plane is healthy. A missing/mismatched marker means a prior
launcher booted an older infra image reboot rather than reuse stale code."""
if not key.exists():
return False
try:
booted = _version_file().read_text(encoding="utf-8").strip()
except OSError:
return False
return booted == want and _health_ok(url)
def _record_booted_version(version: str) -> None:
_version_file().write_text(version + "\n", encoding="utf-8")
# The registry "volume": a host-side ext4 file attached to the infra VM as a # The registry "volume": a host-side ext4 file attached to the infra VM as a
# second virtio-block device (guest /dev/vdb), mounted at the control plane's # second virtio-block device (guest /dev/vdb), mounted at the control plane's
# DB dir. It outlives the ephemeral rootfs, so the bottle registry survives an # DB dir. It outlives the ephemeral rootfs, so the bottle registry survives an
@@ -309,6 +348,28 @@ def _kill_pidfile() -> None:
pass pass
def _kill_infra_firecrackers(proc_root: Path = Path("/proc")) -> None:
"""SIGKILL any firecracker VMM whose `--config-file` is this host's infra
config, independent of the PID file reaps orphans it lost track of so the
orchestrator TAP is free to rebind. Scoped to the infra config path, so the
interactive pool's agent/infra VMs (other config paths) are untouched."""
cfg = str(_infra_dir() / "config.json")
for entry in proc_root.iterdir():
if not entry.name.isdigit():
continue
try:
if (entry / "comm").read_text().strip() != "firecracker":
continue
args = (entry / "cmdline").read_bytes().split(b"\0")
except OSError:
continue # process vanished / not ours
if any(a.decode("utf-8", "replace") == cfg for a in args):
try:
os.kill(int(entry.name), signal.SIGKILL)
except (OSError, ValueError):
pass
def _health_ok(url: str) -> bool: def _health_ok(url: str) -> bool:
try: try:
with urllib.request.urlopen(f"{url}/health", timeout=1.0) as resp: with urllib.request.urlopen(f"{url}/health", timeout=1.0) as resp:
@@ -433,7 +494,7 @@ BOT_BOTTLE_ROOT=/var/lib/bot-bottle python3 -m bot_bottle.orchestrator \\
BOT_BOTTLE_GATEWAY_DAEMONS=egress,git-http,supervise \\ BOT_BOTTLE_GATEWAY_DAEMONS=egress,git-http,supervise \\
BOT_BOTTLE_ORCHESTRATOR_URL=http://127.0.0.1:{CONTROL_PLANE_PORT} \\ BOT_BOTTLE_ORCHESTRATOR_URL=http://127.0.0.1:{CONTROL_PLANE_PORT} \\
SUPERVISE_DB_PATH=/var/lib/bot-bottle/db/bot-bottle.db \\ SUPERVISE_DB_PATH=/var/lib/bot-bottle/db/bot-bottle.db \\
python3 /app/gateway_init.py & python3 -m bot_bottle.gateway_init &
# Reap as PID 1; children are backgrounded, so `wait` blocks. # Reap as PID 1; children are backgrounded, so `wait` blocks.
while : ; do wait ; done while : ; do wait ; done
+5
View File
@@ -368,6 +368,11 @@ mount -t devtmpfs dev /dev 2>/dev/null
mkdir -p /dev/pts && mount -t devpts devpts /dev/pts 2>/dev/null mkdir -p /dev/pts && mount -t devpts devpts /dev/pts 2>/dev/null
mount -o remount,rw / 2>/dev/null mount -o remount,rw / 2>/dev/null
# /tmp must be world-writable + sticky. The rootless rootfs build can land
# it 0755/root-owned, leaving the agent (uid 1000 node) unable to create
# scratch dirs there — git worktrees, build temp, `git init /tmp/...`, etc.
mkdir -p /tmp && chmod 1777 /tmp
# Install the per-bottle SSH pubkey from the kernel cmdline. # Install the per-bottle SSH pubkey from the kernel cmdline.
KEY=$(sed -n 's/.*bb_pubkey=\([^ ]*\).*/\1/p' /proc/cmdline | base64 -d 2>/dev/null) KEY=$(sed -n 's/.*bb_pubkey=\([^ ]*\).*/\1/p' /proc/cmdline | base64 -d 2>/dev/null)
if [ -n "$KEY" ]; then if [ -n "$KEY" ]; then
+8 -1
View File
@@ -38,6 +38,13 @@ COMMANDS = {
"supervise": cmd_supervise, "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: def usage() -> None:
sys.stderr.write(f"usage: {PROG} <command> [args...]\n\n") sys.stderr.write(f"usage: {PROG} <command> [args...]\n\n")
@@ -80,7 +87,7 @@ def main(argv: list[str] | None = None) -> int:
usage() usage()
die(f"unknown command: {command}") die(f"unknown command: {command}")
mgr = StoreManager.instance() mgr = StoreManager.instance()
if not mgr.is_migrated(): if command not in NO_MIGRATION_COMMANDS and not mgr.is_migrated():
sys.stderr.write("bot-bottle: database schema is out of date\n") sys.stderr.write("bot-bottle: database schema is out of date\n")
sys.stderr.write("Migrate now? [y/N] ") sys.stderr.write("Migrate now? [y/N] ")
sys.stderr.flush() sys.stderr.flush()
+5 -17
View File
@@ -23,9 +23,8 @@ from ...agent_provider import (
provider_startup_args, provider_startup_args,
) )
from ...backend.docker import util as docker_mod from ...backend.docker import util as docker_mod
from ...egress import CLAUDE_HOST_CREDENTIAL_TOKEN_REF, EgressRoute from ...egress import EgressRoute
from ...log import die, info, warn from ...log import die, info, warn
from .claude_auth import claude_host_access_token
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -119,6 +118,7 @@ class ClaudeAgentProvider(AgentProvider):
color: str = "", color: str = "",
provider_settings: dict[str, object] | None = None, provider_settings: dict[str, object] | None = None,
) -> AgentProvisionPlan: ) -> AgentProvisionPlan:
del forward_host_credentials, host_env
resolved_guest_env = dict(guest_env or {}) resolved_guest_env = dict(guest_env or {})
startup_args = provider_startup_args(provider_settings) startup_args = provider_startup_args(provider_settings)
guest_home = self.guest_home guest_home = self.guest_home
@@ -180,24 +180,13 @@ class ClaudeAgentProvider(AgentProvider):
claude_settings, claude_settings,
f"{guest_home}/.claude/settings.json", f"{guest_home}/.claude/settings.json",
)) ))
provisioned_env: dict[str, str] = {}
if forward_host_credentials:
_host_env = host_env or dict(os.environ)
provisioned_env[CLAUDE_HOST_CREDENTIAL_TOKEN_REF] = (
claude_host_access_token(_host_env)
)
cred_token_ref = (
CLAUDE_HOST_CREDENTIAL_TOKEN_REF if forward_host_credentials
else auth_token
)
egress_routes = (EgressRoute( egress_routes = (EgressRoute(
host="api.anthropic.com", host="api.anthropic.com",
auth_scheme="Bearer" if (auth_token or forward_host_credentials) else "", auth_scheme="Bearer" if auth_token else "",
token_ref=cred_token_ref, token_ref=auth_token,
),) ),)
hidden_env_names: frozenset[str] = frozenset() hidden_env_names: frozenset[str] = frozenset()
if auth_token or forward_host_credentials: if auth_token:
env_vars["CLAUDE_CODE_OAUTH_TOKEN"] = "egress-placeholder" env_vars["CLAUDE_CODE_OAUTH_TOKEN"] = "egress-placeholder"
hidden_env_names = frozenset({"CLAUDE_CODE_OAUTH_TOKEN"}) hidden_env_names = frozenset({"CLAUDE_CODE_OAUTH_TOKEN"})
@@ -219,7 +208,6 @@ class ClaudeAgentProvider(AgentProvider):
files=tuple(files), files=tuple(files),
egress_routes=egress_routes, egress_routes=egress_routes,
hidden_env_names=hidden_env_names, hidden_env_names=hidden_env_names,
provisioned_env=provisioned_env,
) )
def provision_skills(self, plan: "BottlePlan", bottle: "Bottle") -> None: def provision_skills(self, plan: "BottlePlan", bottle: "Bottle") -> None:
-114
View File
@@ -1,114 +0,0 @@
"""Host Claude auth helpers.
Reads the host's Claude Code credentials and returns only the access
token needed by egress. Does not expose refresh tokens or raw payloads.
Credential storage by platform:
Linux ~/.claude/.credentials.json
macOS macOS Keychain, service "Claude Code-credentials"
(file path is tried first; Keychain is the fallback)
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
from datetime import datetime, timezone
from pathlib import Path
from ...log import die
_KEYCHAIN_SERVICE = "Claude Code-credentials"
def claude_auth_path(host_env: dict[str, str] | None = None) -> Path:
env = os.environ if host_env is None else host_env
home = env.get("HOME")
if home:
return Path(home) / ".claude" / ".credentials.json"
return Path.home() / ".claude" / ".credentials.json"
def _read_keychain() -> dict[str, object] | None:
"""Try the macOS Keychain. Returns parsed JSON dict or None."""
if sys.platform != "darwin":
return None
try:
result = subprocess.run(
["security", "find-generic-password", "-s", _KEYCHAIN_SERVICE, "-w"],
capture_output=True,
text=True,
timeout=10,
)
except (FileNotFoundError, subprocess.TimeoutExpired):
return None
if result.returncode != 0 or not result.stdout.strip():
return None
try:
raw = json.loads(result.stdout.strip())
except json.JSONDecodeError:
return None
return raw if isinstance(raw, dict) else None
def claude_host_access_token(
host_env: dict[str, str] | None = None,
*,
now: datetime | None = None,
) -> str:
path = claude_auth_path(host_env)
raw: dict[str, object] | None = None
if path.is_file():
try:
raw = json.loads(path.read_text())
except (OSError, json.JSONDecodeError) as e:
die(f"claude host credentials: could not read valid JSON at {path}: {e}")
if not isinstance(raw, dict):
die(f"claude host credentials: {path} must contain a JSON object")
else:
raw = _read_keychain()
if raw is None:
die(
f"claude host credentials: auth file missing at {path} and "
f"macOS Keychain lookup for '{_KEYCHAIN_SERVICE}' failed. "
"Run `claude login` on the host or disable "
"agent_provider.forward_host_credentials."
)
oauth = raw.get("claudeAiOauth")
if not isinstance(oauth, dict):
die(
"claude host credentials: claudeAiOauth is missing from credentials. "
"Run `claude login` on the host or disable "
"agent_provider.forward_host_credentials."
)
access_token = oauth.get("accessToken")
if not isinstance(access_token, str) or not access_token:
die(
"claude host credentials: claudeAiOauth.accessToken is missing or empty. "
"Run `claude login` on the host and restart the bottle."
)
# expiresAt is in milliseconds
expires_at = oauth.get("expiresAt")
if isinstance(expires_at, (int, float)):
check_now = now or datetime.now(timezone.utc)
exp_dt = datetime.fromtimestamp(float(expires_at) / 1000.0, timezone.utc)
if exp_dt <= check_now:
die(
"claude host credentials: host Claude access token is expired. "
"Run `claude login` on the host and restart the bottle."
)
return access_token
__all__ = [
"claude_auth_path",
"claude_host_access_token",
]
+12 -2
View File
@@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
import sqlite3 import sqlite3
from contextlib import contextmanager
from pathlib import Path from pathlib import Path
try: try:
@@ -28,12 +29,21 @@ class DbStore:
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
return conn return conn
@contextmanager
def _connection(self):
conn = self._connect()
try:
with conn:
yield conn
finally:
conn.close()
def is_migrated(self) -> bool: def is_migrated(self) -> bool:
"""Return True if the DB is fully up-to-date, False if migration is needed.""" """Return True if the DB is fully up-to-date, False if migration is needed."""
if not self.db_path.exists(): if not self.db_path.exists():
return False return False
try: try:
with self._connect() as conn: with self._connection() as conn:
row = conn.execute( row = conn.execute(
"SELECT version FROM schema_versions WHERE module = ?", "SELECT version FROM schema_versions WHERE module = ?",
(self._migrations.schema_key,), (self._migrations.schema_key,),
@@ -45,7 +55,7 @@ class DbStore:
def migrate(self) -> None: def migrate(self) -> None:
"""Apply any pending migrations and set permissions on the DB file.""" """Apply any pending migrations and set permissions on the DB file."""
with self._connect() as conn: with self._connection() as conn:
self._migrations.apply(conn) self._migrations.apply(conn)
self._chmod() self._chmod()
-2
View File
@@ -30,7 +30,6 @@ if TYPE_CHECKING:
from .manifest import ManifestBottle from .manifest import ManifestBottle
CODEX_HOST_CREDENTIAL_TOKEN_REF = "BOT_BOTTLE_CODEX_HOST_ACCESS_TOKEN" CODEX_HOST_CREDENTIAL_TOKEN_REF = "BOT_BOTTLE_CODEX_HOST_ACCESS_TOKEN"
CLAUDE_HOST_CREDENTIAL_TOKEN_REF = "BOT_BOTTLE_CLAUDE_HOST_ACCESS_TOKEN"
EGRESS_HOSTNAME = "egress" EGRESS_HOSTNAME = "egress"
@@ -401,7 +400,6 @@ class Egress(ABC):
) )
__all__ = [ __all__ = [
"CLAUDE_HOST_CREDENTIAL_TOKEN_REF",
"CODEX_HOST_CREDENTIAL_TOKEN_REF", "CODEX_HOST_CREDENTIAL_TOKEN_REF",
"EGRESS_HOSTNAME", "EGRESS_HOSTNAME",
"EGRESS_ROUTES_FILENAME", "EGRESS_ROUTES_FILENAME",
+17 -11
View File
@@ -419,18 +419,24 @@ PY
while IFS=' ' read -r old new ref; do while IFS=' ' read -r old new ref; do
[ -z "$ref" ] && continue [ -z "$ref" ] && continue
[ "$new" = "$zero" ] && continue [ "$new" = "$zero" ] && continue
if [ "$old" = "$zero" ]; then # Scan only the commits this push introduces — those reachable from
# New ref: scan only the commits this push introduces — those # $new but not from any ref the gate already has. Everything already
# reachable from $new but not from any ref the gate already has. # on the gate arrived via upstream mirror-fetch or a previously
# Everything already on the gate arrived via upstream mirror-fetch # gitleaks-scanned push, so it's already-upstream or already-scanned;
# or a previously gitleaks-scanned push, so it's already-upstream # re-scanning it only resurfaces historical fixture findings.
# or already-scanned; re-scanning it (the old `$new` full-ancestry #
# range) only resurfaces historical findings and blocks every new # Applies to both new refs and updates. The old existing-branch range
# branch. See PRD 0028 / issue #106. # `$old..$new` walks commits reachable from the new tip but not the
# *old branch tip*: on a rebase/force-push onto a freshly-advanced
# main that pulls in all of main's new history (incl. the deliberate
# sandbox-escape gitleaks fixtures), blocking the push. `--not --all`
# excludes anything already on the gate regardless of ancestry, so it
# is also correct for non-fast-forward pushes (a rebase can skip
# commits off the direct path). Security-equivalent per PRD 0028's
# analysis: the bare repo's refs come only from trusted upstream
# mirror-fetch or gitleaks-gated pushes.
# See PRD 0028 (open question) / issues #106, #346.
log_opts="$new --not --all" log_opts="$new --not --all"
else
log_opts="$old..$new"
fi
echo "git-gate: gitleaks scanning $ref ($log_opts)" >&2 echo "git-gate: gitleaks scanning $ref ($log_opts)" >&2
if ! gitleaks git --log-opts="$log_opts" --no-banner --redact 1>&2; then if ! gitleaks git --log-opts="$log_opts" --no-banner --redact 1>&2; then
echo "git-gate: gitleaks rejected push to $ref" >&2 echo "git-gate: gitleaks rejected push to $ref" >&2
+4 -10
View File
@@ -25,9 +25,8 @@ class ManifestAgentProvider:
header, and sets a placeholder CLAUDE_CODE_OAUTH_TOKEN in the agent header, and sets a placeholder CLAUDE_CODE_OAUTH_TOKEN in the agent
so the Claude Code CLI starts. so the Claude Code CLI starts.
`forward_host_credentials` forwards the host provider auth token into `forward_host_credentials` forwards the host Codex auth token into
the egress sidecar (Codex and Claude). For Codex this reads the egress daemon (Codex only).
`~/.codex/auth.json`; for Claude it reads `~/.claude/.credentials.json`.
""" """
template: str = "claude" template: str = "claude"
@@ -93,15 +92,10 @@ class ManifestAgentProvider:
f"is only supported for built-in templates " f"is only supported for built-in templates "
f"({', '.join(sorted(PROVIDER_TEMPLATES))})" f"({', '.join(sorted(PROVIDER_TEMPLATES))})"
) )
if forward_host_credentials and template not in {"codex", "claude"}: if forward_host_credentials and template != "codex":
raise ManifestError( raise ManifestError(
f"bottle '{bottle_name}' agent_provider.forward_host_credentials " f"bottle '{bottle_name}' agent_provider.forward_host_credentials "
"is only supported for templates 'codex' and 'claude'" "is currently only supported for template 'codex'"
)
if forward_host_credentials and auth_token:
raise ManifestError(
f"bottle '{bottle_name}' agent_provider.forward_host_credentials "
"and auth_token both set; use one or the other"
) )
settings = _parse_provider_settings(bottle_name, template, d.get("settings")) settings = _parse_provider_settings(bottle_name, template, d.get("settings"))
return cls( return cls(
+6 -6
View File
@@ -167,7 +167,7 @@ class RegistryStore(DbStore):
metadata=metadata, metadata=metadata,
policy=policy, policy=policy,
) )
with self._connect() as conn: with self._connection() as conn:
conn.execute( conn.execute(
"DELETE FROM orchestrator_bottles " "DELETE FROM orchestrator_bottles "
"WHERE source_ip = ? AND state = 'active' AND bottle_id != ?", "WHERE source_ip = ? AND state = 'active' AND bottle_id != ?",
@@ -193,7 +193,7 @@ class RegistryStore(DbStore):
def set_policy(self, bottle_id: str, policy: str) -> bool: def set_policy(self, bottle_id: str, policy: str) -> bool:
"""Update a bottle's policy in place (live reload). Returns True if """Update a bottle's policy in place (live reload). Returns True if
the bottle exists.""" the bottle exists."""
with self._connect() as conn: with self._connection() as conn:
cur = conn.execute( cur = conn.execute(
"UPDATE orchestrator_bottles SET policy = ? WHERE bottle_id = ?", "UPDATE orchestrator_bottles SET policy = ? WHERE bottle_id = ?",
(policy, bottle_id), (policy, bottle_id),
@@ -203,7 +203,7 @@ class RegistryStore(DbStore):
def deregister(self, bottle_id: str) -> bool: def deregister(self, bottle_id: str) -> bool:
"""Remove a bottle. Returns True if a row was deleted.""" """Remove a bottle. Returns True if a row was deleted."""
with self._connect() as conn: with self._connection() as conn:
cur = conn.execute( cur = conn.execute(
"DELETE FROM orchestrator_bottles WHERE bottle_id = ?", (bottle_id,) "DELETE FROM orchestrator_bottles WHERE bottle_id = ?", (bottle_id,)
) )
@@ -211,7 +211,7 @@ class RegistryStore(DbStore):
def get(self, bottle_id: str) -> BottleRecord | None: def get(self, bottle_id: str) -> BottleRecord | None:
"""Return the bottle by id, or None if absent.""" """Return the bottle by id, or None if absent."""
with self._connect() as conn: with self._connection() as conn:
row = conn.execute( row = conn.execute(
"SELECT * FROM orchestrator_bottles WHERE bottle_id = ?", (bottle_id,) "SELECT * FROM orchestrator_bottles WHERE bottle_id = ?", (bottle_id,)
).fetchone() ).fetchone()
@@ -219,7 +219,7 @@ class RegistryStore(DbStore):
def all(self) -> list[BottleRecord]: def all(self) -> list[BottleRecord]:
"""Every registered bottle, oldest first.""" """Every registered bottle, oldest first."""
with self._connect() as conn: with self._connection() as conn:
rows = conn.execute( rows = conn.execute(
"SELECT * FROM orchestrator_bottles ORDER BY created_at" "SELECT * FROM orchestrator_bottles ORDER BY created_at"
).fetchall() ).fetchall()
@@ -232,7 +232,7 @@ class RegistryStore(DbStore):
source IP is unspoofable (Firecracker `/31` + nft) and the control source IP is unspoofable (Firecracker `/31` + nft) and the control
plane is reachable only by the trusted gateway; pair with the plane is reachable only by the trusted gateway; pair with the
identity token (`attribute`) elsewhere.""" identity token (`attribute`) elsewhere."""
with self._connect() as conn: with self._connection() as conn:
rows = conn.execute( rows = conn.execute(
"SELECT * FROM orchestrator_bottles " "SELECT * FROM orchestrator_bottles "
"WHERE source_ip = ? AND state = 'active'", "WHERE source_ip = ? AND state = 'active'",
+7 -7
View File
@@ -66,7 +66,7 @@ class QueueStore(DbStore):
super().__init__(resolved, migrations) super().__init__(resolved, migrations)
def write_proposal(self, proposal: Proposal) -> Path: def write_proposal(self, proposal: Proposal) -> Path:
with self._connect() as conn: with self._connection() as conn:
conn.execute( conn.execute(
""" """
INSERT OR REPLACE INTO supervise_proposals ( INSERT OR REPLACE INTO supervise_proposals (
@@ -89,7 +89,7 @@ class QueueStore(DbStore):
return self.db_path return self.db_path
def read_proposal(self, proposal_id: str) -> Proposal: def read_proposal(self, proposal_id: str) -> Proposal:
with self._connect() as conn: with self._connection() as conn:
row = conn.execute( row = conn.execute(
""" """
SELECT * FROM supervise_proposals SELECT * FROM supervise_proposals
@@ -104,7 +104,7 @@ class QueueStore(DbStore):
def list_pending_proposals(self) -> list[Proposal]: def list_pending_proposals(self) -> list[Proposal]:
if not self.db_path.is_file(): if not self.db_path.is_file():
return [] return []
with self._connect() as conn: with self._connection() as conn:
rows = conn.execute( rows = conn.execute(
""" """
SELECT p.* FROM supervise_proposals p SELECT p.* FROM supervise_proposals p
@@ -125,7 +125,7 @@ class QueueStore(DbStore):
def list_all_pending_proposals(self) -> list[Proposal]: def list_all_pending_proposals(self) -> list[Proposal]:
if not self.db_path.is_file(): if not self.db_path.is_file():
return [] return []
with self._connect() as conn: with self._connection() as conn:
rows = conn.execute( rows = conn.execute(
""" """
SELECT p.* FROM supervise_proposals p SELECT p.* FROM supervise_proposals p
@@ -142,7 +142,7 @@ class QueueStore(DbStore):
return [self._row_to_proposal(row) for row in rows] return [self._row_to_proposal(row) for row in rows]
def write_response(self, response: Response) -> Path: def write_response(self, response: Response) -> Path:
with self._connect() as conn: with self._connection() as conn:
conn.execute( conn.execute(
""" """
INSERT OR REPLACE INTO supervise_responses ( INSERT OR REPLACE INTO supervise_responses (
@@ -161,7 +161,7 @@ class QueueStore(DbStore):
return self.db_path return self.db_path
def read_response(self, proposal_id: str) -> Response: def read_response(self, proposal_id: str) -> Response:
with self._connect() as conn: with self._connection() as conn:
row = conn.execute( row = conn.execute(
""" """
SELECT * FROM supervise_responses SELECT * FROM supervise_responses
@@ -176,7 +176,7 @@ class QueueStore(DbStore):
def archive_proposal(self, proposal_id: str) -> None: def archive_proposal(self, proposal_id: str) -> None:
if not self.db_path.is_file(): if not self.db_path.is_file():
return return
with self._connect() as conn: with self._connection() as conn:
conn.execute( conn.execute(
""" """
UPDATE supervise_proposals SET archived = 1 UPDATE supervise_proposals SET archived = 1
+2
View File
@@ -47,6 +47,7 @@ from .supervise_types import (
STATUS_MODIFIED, STATUS_MODIFIED,
STATUS_REJECTED, STATUS_REJECTED,
TOOLS, TOOLS,
TOOL_CHECK_PROPOSAL,
TOOL_EGRESS_ALLOW, TOOL_EGRESS_ALLOW,
TOOL_EGRESS_BLOCK, TOOL_EGRESS_BLOCK,
TOOL_EGRESS_TOKEN_ALLOW, TOOL_EGRESS_TOKEN_ALLOW,
@@ -263,6 +264,7 @@ __all__ = [
"TOOLS", "TOOLS",
"EGRESS_FORWARD_PROXY", "EGRESS_FORWARD_PROXY",
"EGRESS_INTROSPECT_URL", "EGRESS_INTROSPECT_URL",
"TOOL_CHECK_PROPOSAL",
"TOOL_EGRESS_ALLOW", "TOOL_EGRESS_ALLOW",
"TOOL_EGRESS_BLOCK", "TOOL_EGRESS_BLOCK",
"TOOL_GITLEAKS_ALLOW", "TOOL_GITLEAKS_ALLOW",
+122 -9
View File
@@ -2,14 +2,24 @@
Per-bottle MCP server exposing tools the agent calls to propose egress Per-bottle MCP server exposing tools the agent calls to propose egress
config changes when stuck. The tools are `egress-allow`, config changes when stuck. The tools are `egress-allow`,
`egress-block`, and `list-egress-routes`. `egress-block`, `list-egress-routes`, and `check-proposal`.
Each queued tool call: Each queued proposal tool call:
1. Validates the proposed file syntactically. 1. Validates the proposed file syntactically.
2. Writes a Proposal to the host SQLite database. 2. Writes a Proposal to the host SQLite database.
3. Blocks polling for a matching Response row. 3. Blocks polling for a matching Response row, up to a short grace
4. Returns the operator's `{status, notes}` to the agent. window (`SUPERVISE_RESPONSE_TIMEOUT_SECONDS`, default 30s).
4. On a decision within the window, returns the operator's
`{status, notes}`. On timeout, returns `status: pending` **with the
proposal id** and leaves the proposal queued the flow is
non-blocking past the grace window (PRD prd-new / issue #412).
`check-proposal` is the non-blocking companion: given a `proposal_id`
returned by a `pending` response, it reports the current decision
(`pending` | `approved` | `modified` | `rejected`) without re-proposing,
so an approval made out-of-band (e.g. a web review console) can be resumed
without holding an HTTP request open.
One shared server fronts every bottle (PRD 0070) and attributes each One shared server fronts every bottle (PRD 0070) and attributes each
proposal to the calling bottle by source IP, resolved from the orchestrator proposal to the calling bottle by source IP, resolved from the orchestrator
@@ -22,7 +32,9 @@ Speaks MCP over HTTP+JSON-RPC. Methods handled:
* `initialize` handshake; returns server info + caps. * `initialize` handshake; returns server info + caps.
* `notifications/initialized` ack-only. * `notifications/initialized` ack-only.
* `tools/list` returns the tool definitions. * `tools/list` returns the tool definitions.
* `tools/call` validates, queues, blocks, returns. * `tools/call` validates, queues, waits out the grace
window, returns (pending past it); or, for
`check-proposal`, a non-blocking status poll.
Everything else returns JSON-RPC error -32601 (method not found). Everything else returns JSON-RPC error -32601 (method not found).
@@ -232,6 +244,31 @@ TOOL_DEFINITIONS: list[dict[str, object]] = [
), ),
"inputSchema": _proposal_input_schema(), "inputSchema": _proposal_input_schema(),
}, },
{
"name": _sv.TOOL_CHECK_PROPOSAL,
"description": (
"Poll a previously queued proposal for the operator's decision "
"WITHOUT blocking or re-proposing. Pass the `proposal_id` you "
"got back when an `egress-allow`/`egress-block` call returned "
"`status: pending`. Returns the current status: `pending` (no "
"decision yet — poll again later), `approved`, `modified`, "
"`rejected`, or `unknown` (no such queued proposal — wrong id, "
"or it was already resolved and read)."
),
"inputSchema": {
"type": "object",
"properties": {
"proposal_id": {
"type": "string",
"description": (
"The proposal id from a `pending` response."
),
},
},
"required": ["proposal_id"],
"additionalProperties": False,
},
},
] ]
@@ -353,7 +390,7 @@ def handle_tools_call(
deadline=deadline, deadline=deadline,
) )
except TimeoutError: except TimeoutError:
text = format_pending_response_text(config.response_timeout_seconds) text = format_pending_response_text(proposal.id, config.response_timeout_seconds)
return { return {
"content": [{"type": "text", "text": text}], "content": [{"type": "text", "text": text}],
"isError": False, "isError": False,
@@ -370,6 +407,54 @@ def handle_tools_call(
} }
def handle_check_proposal(
params: dict[str, object],
config: ServerConfig,
) -> dict[str, object]:
"""Non-blocking poll of a queued proposal's decision, by id.
Never creates a Proposal (so `check-proposal` isn't in `TOOLS`); it only
reads the queue. Resolution order mirrors the synchronous path's terminal
step a decided proposal is archived here exactly as `handle_tools_call`
archives it after `wait_for_response`, so `pending` proposals stay visible
to the operator until they're both decided *and* polled."""
args_raw = params.get("arguments", {})
if not isinstance(args_raw, dict):
raise _RpcClientError(ERR_INVALID_PARAMS, "tools/call 'arguments' must be an object")
proposal_id = args_raw.get("proposal_id")
if not isinstance(proposal_id, str) or not proposal_id.strip():
raise _RpcClientError(
ERR_INVALID_PARAMS,
"check-proposal: 'proposal_id' is required and must be a non-empty string",
)
proposal_id = proposal_id.strip()
try:
response = _sv.read_response(config.bottle_slug, proposal_id)
except FileNotFoundError:
# No decision yet — distinguish "still queued" from "unknown id".
try:
_sv.read_proposal(config.bottle_slug, proposal_id)
except FileNotFoundError:
return {
"content": [{"type": "text", "text": format_unknown_proposal_text(proposal_id)}],
"isError": True,
}
return {
"content": [{"type": "text", "text": format_still_pending_text(proposal_id)}],
"isError": False,
}
try:
_sv.archive_proposal(config.bottle_slug, proposal_id)
except OSError as e:
raise _RpcInternalError(f"failed to archive proposal: {e}") from e
return {
"content": [{"type": "text", "text": format_response_text(response)}],
"isError": response.status == _sv.STATUS_REJECTED,
}
def format_response_text(response: "_sv.Response") -> str: def format_response_text(response: "_sv.Response") -> str:
"""Pretty-print a Response for the tool's text content. The agent """Pretty-print a Response for the tool's text content. The agent
reads the text and decides whether to retry / give up / surface.""" reads the text and decides whether to retry / give up / surface."""
@@ -382,12 +467,35 @@ def format_response_text(response: "_sv.Response") -> str:
return "\n".join(lines) return "\n".join(lines)
def format_pending_response_text(timeout_seconds: float) -> str: def format_pending_response_text(proposal_id: str, timeout_seconds: float) -> str:
"""Grace-window timeout: the proposal stays queued, and the agent is
told the id so it can `check-proposal` instead of re-proposing."""
return "\n".join([ return "\n".join([
"status: pending", "status: pending",
f"proposal_id: {proposal_id}",
( (
"notes: operator response timed out after " f"notes: no operator decision within {timeout_seconds:g}s; the "
f"{timeout_seconds:g}s; proposal remains queued" "proposal remains queued. Poll it (do not re-propose) by calling "
f"`check-proposal` with proposal_id={proposal_id!r}."
),
])
def format_still_pending_text(proposal_id: str) -> str:
return "\n".join([
"status: pending",
f"proposal_id: {proposal_id}",
"notes: still queued; no operator decision yet. Call `check-proposal` again later.",
])
def format_unknown_proposal_text(proposal_id: str) -> str:
return "\n".join([
"status: unknown",
f"proposal_id: {proposal_id}",
(
"notes: no queued proposal with this id for this bottle — the id "
"may be wrong, or the proposal was already resolved and read."
), ),
]) ])
@@ -482,6 +590,11 @@ class MCPHandler(http.server.BaseHTTPRequestHandler):
# — silently dropping base routes like api.anthropic.com on approval. # — silently dropping base routes like api.anthropic.com on approval.
if req.params.get("name") == _sv.TOOL_LIST_EGRESS_ROUTES: if req.params.get("name") == _sv.TOOL_LIST_EGRESS_ROUTES:
return self._resolved_routes_payload() return self._resolved_routes_payload()
# `check-proposal` is a non-blocking read of the calling bottle's
# own queue — attributed by source IP like a proposal, but it
# never queues or blocks.
if req.params.get("name") == _sv.TOOL_CHECK_PROPOSAL:
return handle_check_proposal(req.params, self._attributed_config(config))
# Attribute the proposal to the source-IP-resolved bottle, so the one # Attribute the proposal to the source-IP-resolved bottle, so the one
# shared server queues each bottle's proposal under its own slug. # shared server queues each bottle's proposal under its own slug.
return handle_tools_call(req.params, self._attributed_config(config)) return handle_tools_call(req.params, self._attributed_config(config))
+5
View File
@@ -20,6 +20,10 @@ TOOL_EGRESS_ALLOW = "egress-allow"
TOOL_GITLEAKS_ALLOW = "gitleaks-allow" TOOL_GITLEAKS_ALLOW = "gitleaks-allow"
TOOL_EGRESS_TOKEN_ALLOW = "egress-token-allow" TOOL_EGRESS_TOKEN_ALLOW = "egress-token-allow"
TOOL_LIST_EGRESS_ROUTES = "list-egress-routes" TOOL_LIST_EGRESS_ROUTES = "list-egress-routes"
# Read-only agent tool: poll a queued proposal for the operator's decision
# without blocking or re-proposing. It never becomes a `Proposal.tool` (no
# queue record is created for it), so it is intentionally NOT in `TOOLS`.
TOOL_CHECK_PROPOSAL = "check-proposal"
TOOLS: tuple[str, ...] = ( TOOLS: tuple[str, ...] = (
TOOL_EGRESS_ALLOW, TOOL_EGRESS_ALLOW,
TOOL_EGRESS_BLOCK, TOOL_EGRESS_BLOCK,
@@ -156,6 +160,7 @@ __all__ = [
"TOOLS", "TOOLS",
"TOOL_EGRESS_ALLOW", "TOOL_EGRESS_ALLOW",
"TOOL_EGRESS_BLOCK", "TOOL_EGRESS_BLOCK",
"TOOL_CHECK_PROPOSAL",
"TOOL_EGRESS_TOKEN_ALLOW", "TOOL_EGRESS_TOKEN_ALLOW",
"TOOL_GITLEAKS_ALLOW", "TOOL_GITLEAKS_ALLOW",
"TOOL_LIST_EGRESS_ROUTES", "TOOL_LIST_EGRESS_ROUTES",
@@ -0,0 +1,57 @@
# ADR 0005: Keep tracker metadata on issues
- **Status:** Accepted
- **Date:** 2026-07-18
- **Deciders:** didericis
## Context
Gitea exposes labels on both issues and pull requests. Applying the same labels
to both copies planning metadata, creates a synchronization obligation, and
makes disagreements between the two records possible. At the same time,
unlabelled objects look accidental unless the repository states which object
owns the metadata.
The repository already uses issues as work items and PRs as implementations of
those work items. At this decision's cutoff, all open PRs reference issues, but
121 of 219 historically merged PRs do not. Manufacturing retrospective issues
for that history would create records that never participated in planning and
would make the issue history less truthful.
## Decision
Issues are the canonical tracker records and own labels. Every issue has at
least one label. An issue opened or left without labels receives
`Status/Needs Triage` automatically until it is classified.
Pull requests carry no labels. Every new PR deliberately references at least
one existing issue in its title or description with one of these forms:
- `Closes #123`, `Fixes #123`, or `Resolves #123` when merging completes it.
- `Part of #123`, `Related to #123`, `Refs #123`, or `References #123` when it
contributes without completing it.
Gitea Actions enforces both PR rules as a status check and repairs the empty
issue-label state. Branch protection makes the PR policy check required.
The policy applies from 2026-07-18 onward. Existing issues may be labelled as
they are encountered, but closed PRs are grandfathered: no retrospective
issues or PR labels are created solely to make history conform.
## Consequences
- Classification, priority, and workflow metadata have one source of truth.
- A PR's issue link is the navigation path to its planning metadata.
- Multi-PR issues do not require copied or synchronized labels.
- `Status/Needs Triage` is an intentional fallback, not a final
classification.
- Direct issue creation remains convenient; automation repairs a missing label
immediately after creation because Gitea has no native required-label rule.
- The required check must be configured in branch protection after this
workflow lands.
## Links
- Issue #405.
- `.gitea/workflows/tracker-policy.yml`.
- `scripts/tracker_policy.py`.
@@ -1,146 +0,0 @@
# PRD prd-new: Claude forward_host_credentials
- **Status:** Draft
- **Author:** claude
- **Created:** 2026-07-01
- **Issue:** #325
## Summary
Add `agent_provider.forward_host_credentials: true` support for the
`claude` template, mirroring the existing Codex flow. When enabled,
bot-bottle reads the host's Claude OAuth session key from
`~/.claude/.credentials.json` at launch, forwards it only to the egress sidecar,
and injects a placeholder `CLAUDE_CODE_OAUTH_TOKEN` into the agent so
Claude Code starts without ever seeing the real credential.
## Problem
Running a Claude agent in a container today requires the operator to
manually extract a long-lived OAuth token (`claude setup-token`), export
it as `BOT_BOTTLE_CLAUDE_OAUTH_TOKEN`, and reference it explicitly in
the manifest with `agent_provider.auth_token:
"BOT_BOTTLE_CLAUDE_OAUTH_TOKEN"`. This is a two-step manual ceremony
that is easy to skip or do incorrectly.
The host already stores a valid Claude session in `~/.claude/.credentials.json`
after `claude login`. Codex already automates an
equivalent extraction from `~/.codex/auth.json`. There is no reason
Claude bottles cannot do the same.
## Goals / Success Criteria
- A Claude bottle with `forward_host_credentials: true` in the manifest
uses the host's `~/.claude/.credentials.json` session key at launch with no
additional operator steps.
- The agent container receives only `CLAUDE_CODE_OAUTH_TOKEN=egress-placeholder`
— never the real token.
- The real session key lives only in the egress sidecar's environment.
- Missing, malformed, or expired host Claude auth fails launch with a
clear operator-facing message.
- Existing `auth_token` behavior is unchanged.
- `forward_host_credentials: true` is rejected in the manifest when both
`auth_token` and `forward_host_credentials` are set, since they serve
the same purpose.
## Non-goals
- Refreshing Claude OAuth tokens in the sidecar.
- Writing a dummy `~/.claude.json` auth state to the agent (unlike the
Codex flow, Claude Code reads its credential from `CLAUDE_CODE_OAUTH_TOKEN`
in env, not from an auth file — no guest-side auth marker is needed).
- Supporting `forward_host_credentials` for providers other than `codex`
and `claude`.
## Design
### Manifest schema
```yaml
agent_provider:
template: claude
forward_host_credentials: true
```
Rejects in manifest validation when:
- Template is not `codex` or `claude`.
- Both `auth_token` and `forward_host_credentials` are set.
### Host auth extraction (`contrib/claude/claude_auth.py`)
Claude Code credential storage varies by platform:
- **Linux**: `~/.claude/.credentials.json`
- **macOS**: macOS Keychain, service `"Claude Code-credentials"`
(the file path is tried first; Keychain is the fallback when the file
is absent)
`~/.claude.json` contains only UI state and profile metadata — no token.
The credentials JSON schema (same whether from file or Keychain):
```json
{
"claudeAiOauth": {
"accessToken": "<access-token>",
"refreshToken": "<refresh-token>",
"expiresAt": 1748276587173,
"scopes": ["user:inference", "user:profile"]
}
}
```
`expiresAt` is in **milliseconds** (not seconds).
At prepare/launch time, when `forward_host_credentials: true`:
1. Try `~/.claude/.credentials.json`; on macOS, if absent, run
`security find-generic-password -s "Claude Code-credentials" -w`
and parse its stdout as JSON.
2. Require a `claudeAiOauth` dict.
3. Require a non-empty `claudeAiOauth.accessToken` string.
4. If `claudeAiOauth.expiresAt` is present, divide by 1000 and require
the result to be in the future.
5. Return only the access token to the launch path.
Errors name the missing or invalid condition and point the operator at
`claude login`, without printing token values.
### Egress route
When `forward_host_credentials: true`:
- Provision the session key in `provisioned_env` under
`BOT_BOTTLE_CLAUDE_HOST_ACCESS_TOKEN` (new constant in `egress.py`).
- Set up the `api.anthropic.com` egress route with `auth_scheme: Bearer`
and `token_ref: BOT_BOTTLE_CLAUDE_HOST_ACCESS_TOKEN`.
- Set `CLAUDE_CODE_OAUTH_TOKEN=egress-placeholder` in the agent env and
add it to `hidden_env_names`.
No dummy auth file and no `verify` step are needed — Claude Code reads
the credential from the env var, not from a file.
### Constants
- `CLAUDE_HOST_CREDENTIAL_TOKEN_REF = "BOT_BOTTLE_CLAUDE_HOST_ACCESS_TOKEN"`
in `egress.py` (alongside the existing `CODEX_HOST_CREDENTIAL_TOKEN_REF`).
- `CLAUDE_HOST_CREDENTIAL_HOSTS = ("api.anthropic.com",)` in
`agent_provider.py` (alongside the existing `CODEX_HOST_CREDENTIAL_HOSTS`).
### Data flow
```
Host ~/.claude/.credentials.json → bot-bottle launch
├──► egress sidecar env (real token only)
└──► agent env: CLAUDE_CODE_OAUTH_TOKEN=egress-placeholder
Agent → HTTPS to api.anthropic.com (via egress)
Egress → injects Authorization: Bearer <real token>
Egress → forwards to api.anthropic.com
```
## Open questions
None — the Codex precedent makes the design clear.
+125
View File
@@ -0,0 +1,125 @@
# PRD prd-new: Non-blocking supervise (async approval + proposal polling)
- **Status:** Draft
- **Author:** didericis
- **Created:** 2026-07-18
- **Issue:** #412
## Summary
The per-bottle supervise MCP server (`bot_bottle/supervise_server.py`)
answers `tools/call` **synchronously**: it queues the agent's proposal and
blocks the tool call polling for the operator's decision. On timeout it
returns `status: pending` and leaves the proposal queued — but it hands the
agent **no proposal id** and offers **no way to poll a specific pending
proposal**, so the only way to learn the outcome is to re-propose (a
duplicate).
This PRD makes the MCP flow non-blocking and pollable, so an approval can
happen out-of-band (a human taking minutes-to-hours in a review console)
without holding an HTTP request open or wedging the agent:
1. Include the `proposal_id` in the `pending` response.
2. Add a `check-proposal` MCP tool: a non-blocking status lookup by
proposal id.
3. Keep the short synchronous grace window for the common "operator is
right there" fast path.
## Problem
`handle_tools_call``_sv.wait_for_response(...)` blocks up to
`SUPERVISE_RESPONSE_TIMEOUT_SECONDS` (default 30s). Two problems follow:
- **Human latency ≠ tool-call latency.** A real review — rendered diff,
RBAC routing to an approver, someone tapping approve on their phone — is
minutes-to-hours. Holding the MCP request open that long is fragile
(proxy/keepalive timeouts, the mitmproxy egress hop, and the agent
harness's own tool-call timeout, which a long block can trip and stall
the whole turn).
- **No resume path.** The pending fallback already exists, but without a
proposal id and a poll tool the agent can't reconnect to that specific
decision — it re-proposes, duplicating the queue entry.
This is also the precondition for the planned web-console human-review
flow (RBAC, audit retention, mobile) — see issue #412.
**Safety note:** the MCP tools only *propose* policy changes; enforcement
stays at the egress proxy and the git-gate. Returning early on `pending`
therefore opens no hole — the agent still cannot egress or push anything
unapproved.
## Goals / success criteria
- A `pending` MCP response carries the `proposal_id`.
- An agent can call `check-proposal(proposal_id)` and get the current
state (`pending` | `approved` | `modified` | `rejected`) **without
blocking** and **without creating a new proposal**.
- The synchronous fast path (operator approves within the grace window) is
unchanged: the first `tools/call` still returns the decision directly.
- No change to enforcement, attribution (source-IP → bottle), or the
operator-side queue/response schema.
## Non-goals
- The git-gate `pre-receive` path (it is synchronous by nature and cannot
poll — its async variant is reject-fast + re-push; tracked as a
follow-up).
- Backpressure / in-flight-proposal caps.
- MCP server→client notifications (event-driven resume).
- Any web-console UI (this PRD is the protocol groundwork it needs).
## Design
### `pending` response carries the id
`handle_tools_call`'s timeout branch formats the pending text with the
`proposal.id` and a pointer to `check-proposal`, so the agent knows what to
poll.
### `check-proposal` tool
A new read-only MCP tool (`TOOL_CHECK_PROPOSAL = "check-proposal"`),
attributed to the calling bottle by source IP exactly like the proposal
tools. Input: `{ "proposal_id": string }`. Behavior:
1. `read_response(slug, id)`
- **found**: archive the proposal (same terminal step the synchronous
path takes) and return the decision via `format_response_text`;
`isError` iff rejected.
2. **not found**`read_proposal(slug, id)`
- **found**: still queued → return `status: pending`.
- **not found**: unknown id, or already resolved-and-archived (e.g. a
second poll) → return `status: unknown`, `isError: true`.
Both lookups already raise `FileNotFoundError` when absent
(`queue_store.py`), so the handler needs no new store methods. `check-`
`proposal` is the only path (besides the synchronous response) that
archives, so a proposal that times out to `pending` stays visible to the
operator until it is decided and then polled.
### Grace window
Left at the existing 30s default (`SUPERVISE_RESPONSE_TIMEOUT_SECONDS`),
which doubles as the instant-approve fast path. Tuning it down is an
operator setting, not a code change; noted for the console rollout.
## Implementation chunks
1. **(this PR)** `TOOL_CHECK_PROPOSAL` constant; `check-proposal` tool
definition + `handle_check_proposal`; dispatch wiring; `proposal_id` in
the pending text; unit tests. Files: `bot_bottle/supervise_types.py`,
`bot_bottle/supervise.py` (re-export), `bot_bottle/supervise_server.py`,
`tests/unit/test_supervise_server.py`.
2. **(follow-up)** git-gate `pre-receive` reject-fast + re-push.
3. **(follow-up)** per-bottle in-flight-proposal backpressure cap.
4. **(follow-up)** MCP notifications for event-driven resume; web-console
review flow (RBAC, audit retention) on top.
## Open questions
- Should a resolved-but-unpolled proposal auto-archive after some TTL, or
only on poll? (Leaning: only on poll, so a decision is never lost to a
reaper before the agent sees it.)
- Does the agent harness need an explicit "you have a pending proposal"
nudge, or is returning `pending` from the original call enough? (Deferred
to the notifications chunk.)
+456 -44
View File
@@ -6,27 +6,61 @@ general AI-agent sandbox / containment projects — some Claude-specific,
some agent-agnostic, some hosted SaaS — and contrasts them with some agent-agnostic, some hosted SaaS — and contrasts them with
bot-bottle's design. bot-bottle's design.
Research conducted 2026-05-11. Research conducted 2026-05-11. CubeSandbox added 2026-07-18 (see its
per-project note and the addendum at the end). Also updated 2026-07-18:
bot-bottle no longer uses **pipelock** — outbound DLP is now bot-bottle's
own (deliberately simple) egress scanner (a mitmproxy addon with custom
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 ## Summary
Eight projects surveyed. None duplicate bot-bottle's combination of Fifteen projects surveyed across two categories: isolation/sandbox tools
local Docker, declarative JSON manifest, per-agent egress allowlist via and governance/pre-action authorization layers (the latter don't provide
pipelock, and bottle/agent split. Two clusters stand out: 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:
- **Closest neighbours** — agent-safehouse and litterbox: local, - **Closest neighbours** — agent-safehouse and litterbox: local,
single-user, thin wrappers over an existing OS primitive single-user, thin wrappers over an existing OS primitive
(`sandbox-exec`, Podman + Landlock). (`sandbox-exec`, Podman + Landlock).
- **Different category** — tilde.run (hosted SaaS), boxlite and - **Different category (isolation)** — tilde.run (hosted SaaS), boxlite
microsandbox (microVM libraries for platform builders), endo-familiar and microsandbox (microVM libraries for platform builders), CubeSandbox
(self-hosted multi-tenant microVM service), endo-familiar
(capability-security paradigm, no OS isolation). (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) is The microVM cluster (matchlock, smolmachines, boxlite, microsandbox,
the most relevant for the v2 isolation discussion in CubeSandbox) is the most relevant for the v2 isolation discussion in
[`stronger-isolation-alternatives.md`](stronger-isolation-alternatives.md): [`stronger-isolation-alternatives.md`](stronger-isolation-alternatives.md):
libkrun and Apple's Virtualization.framework have made local microVMs libkrun and Apple's Virtualization.framework have made local microVMs
ergonomic enough that a `"runtime": "microvm"` option on a bottle is now ergonomic enough that microVMs are **now bot-bottle's default backend**
plausible without a heavy stack. (Firecracker on KVM Linux, Apple Container on macOS), with Docker kept
only as a legacy fallback for CI / hosts without KVM or Apple Container.
That discussion has since shipped, not just been theorized.
**The one that matters most for positioning is CubeSandbox** — it is the
first surveyed project to ship bot-bottle's would-be wedge (default-deny
egress allowlist + full audit logs + in-flight credential custody so keys
never enter the sandbox) *combined with* per-sandbox microVM isolation,
open-source under Apache 2.0, with Tencent Cloud behind it and 10.4k
stars. It's a self-hosted multi-tenant service for platform builders, not
a single-user declarative tool, so it doesn't collide head-on — but it
narrows the "nobody else bundles egress custody + credential injection"
claim that the monetization positioning leans on. See the addendum.
## Per-project notes ## Per-project notes
@@ -155,67 +189,272 @@ plausible without a heavy stack.
also supported. also supported.
- **Maturity**: Active through April 2026. - **Maturity**: Active through April 2026.
### CubeSandbox *(added 2026-07-18)*
- **Source**: https://github.com/TencentCloud/CubeSandbox ;
HN launch https://news.ycombinator.com/item?id=47863430
- **License**: Apache 2.0 (~10.4k stars). By Tencent Cloud; described as
"battle-tested, production-ready" infra already running in Tencent
Cloud. Rust / Go / C.
- **Isolation**: MicroVMs via RustVMM + KVM — "each sandbox gets its own
Guest OS kernel, no Docker shared-kernel escapes." Hardware-level
isolation, dedicated kernel per instance.
- **Locality**: Self-hosted, but **server/cluster-oriented**, not a
single-user local CLI. Deploy guides target PVM cloud VMs, bare metal,
and dev. A single 96-vCPU host is claimed to run 2,000+ concurrent
sandboxes.
- **Agent integration**: **Drop-in E2B SDK replacement** (single env-var
change) — the headline compatibility claim. OpenClaw assistant
integration; general LLM-code execution. Aimed at platform builders,
not one developer's laptop.
- **Config**: Programmatic via the E2B-compatible SDK. No declarative
manifest.
- **Network policy**: This is the striking part — **domain allowlists,
instant block on unauthorized egress, full audit logs, per-sandbox
traffic tokens, policy-routing egress**, enforced by an eBPF-based
virtual switch giving kernel-level network isolation. Closest match yet
to bot-bottle's own default-deny + per-bottle allowlist egress model.
- **Credentials**: **Credential vault** — agents call external APIs / LLMs
while "keys never enter the sandbox, model context, or logs." Same
in-flight-injection idea as matchlock, but productized as a vault.
- **Performance**: <60ms cold start (claimed 2.550× faster than
alternatives), <5MB memory per instance; millisecond snapshot rollback
is upcoming.
- **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 ## Comparison table
| Axis | bot-bottle | endo-familiar | litterbox | agent-safehouse | matchlock | tilde.run | boxlite | microsandbox | smolmachines | *Isolation/sandbox tools only. AGT and OAP are governance layers — see their per-project notes above.*
|---|---|---|---|---|---|---|---|---|---|
| Isolation | Docker + internal net + pipelock; gVisor 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) | | Axis | bot-bottle | endo-familiar | litterbox | agent-safehouse | matchlock | tilde.run | boxlite | microsandbox | smolmachines | CubeSandbox | Cleanroom | container-use | Docker sbx | Anthropic srt |
| Local vs hosted | Local | Local | Local (Linux) | Local (macOS) | Local | Hosted SaaS | 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 | | 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 |
| Agent target | Claude Code | Generic (demo) | Generic | Multi-agent wrapper | Generic (+ Claude/OpenAI SDKs) | Claude focus | Generic | Claude + Cursor (MCP/Skills) | Generic (AGENTS.md) | | 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 |
| Network policy | Default-deny via pipelock + per-bottle allowlist + DLP | 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 | | 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) |
| Parallel agents | Yes (one bottle per agent) | n/a | Not addressed | One at a time | Multiple VMs | Yes (dashboard) | SDK-level | SDK-level | Architectural | | 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) |
| Config | JSON manifest (bottles + agents) | Programmatic refs | CLI wizard | Profile files / shell fns | CLI / SDK | DSL + CLI + SDK | SDK | CLI / SDK / MCP | TOML Smolfile | | 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 |
| Maturity | Active May 2026 | Research (2022+) | Early (~66 ⭐) | Active (~1.4k ⭐) | Experimental (~574 ⭐) | Private preview | YC, ~4.7k ⭐ | YC, ~6k ⭐, beta | ~3.1k ⭐ | | 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 |
## What's closest, what's different ## What's closest, what's different
**Closest in design and scope.** agent-safehouse and litterbox sit **Closest in design and scope.** agent-safehouse and litterbox sit
nearest bot-bottle: local, single-user, thin wrappers over an nearest bot-bottle: local, single-user, thin wrappers over an
existing OS primitive, low-dep. The split is the isolation primitive — existing OS primitive, low-dep. The split is the isolation primitive —
bot-bottle uses Docker + pipelock egress (plus gVisor where bot-bottle now defaults to a VM per bottle (Firecracker microVM on KVM
available); agent-safehouse uses `sandbox-exec`; litterbox uses Podman + Linux, Apple Container on macOS) with its own DLP-scanning egress proxy,
Landlock. matchlock and smolmachines are spiritually close on the keeping Docker only as a legacy fallback; agent-safehouse uses
*policy* side (default-deny net, per-host allowlist) but use microVMs `sandbox-exec`; litterbox uses Podman + Landlock. matchlock and
instead of containers. 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.
**Solving a different problem.** tilde.run is hosted SaaS for team / **Solving a different problem.** tilde.run is hosted SaaS for team /
production agent pipelines with data-versioned rollback — explicitly production agent pipelines with data-versioned rollback — explicitly
opposite to bot-bottle's "infrastructure I control" goal. boxlite and opposite to bot-bottle's "infrastructure I control" goal. boxlite,
microsandbox are infrastructure libraries aimed at platform builders microsandbox, and CubeSandbox are infrastructure libraries/services aimed
embedding sandboxes into agent frameworks; they would be a *backend* at platform builders embedding sandboxes into agent frameworks; they
bot-bottle could call, not a competitor to its manifest layer. would be a *backend* bot-bottle could call, not a competitor to its
endo-familiar is in a different paradigm entirely: capability passing manifest layer. endo-familiar is in a different paradigm entirely:
rather than kernel boundaries. capability passing rather than kernel boundaries.
## Borrowable ideas ## Borrowable ideas
What bot-bottle already has that the survey suggested as What bot-bottle already has that the survey suggested as
differentiators: differentiators:
- Default-deny egress with a per-agent allowlist (pipelock). - Default-deny egress with a per-agent allowlist (own egress scanner).
- DLP scanning of outbound traffic. - DLP scanning of outbound traffic.
- Bottle / agent split (manifest layer above the isolation primitive). - Bottle / agent split (manifest layer above the isolation primitive).
- gVisor auto-detection on Linux. - gVisor auto-detection on Linux.
Ideas worth considering, without abandoning the Python-stdlib-first / local-Docker Ideas worth considering, without abandoning the Python-stdlib-first /
stance: local, single-operator stance:
1. **Per-use SSH key confirmation** (from litterbox). Even with 1. **Per-use SSH key confirmation** (from litterbox). Even with
KnownHostKey pinning and pipelock egress, a wrapper SSH agent that KnownHostKey pinning and the egress DLP scanner, a wrapper SSH agent that
prompts on each key use (e.g. via `osascript` / `notify-send`) would prompts on each key use (e.g. via `osascript` / `notify-send`) would
catch an agent doing something off-policy with a key it legitimately catch an agent doing something off-policy with a key it legitimately
holds. Pure-stdlib, no new deps. holds. Pure-stdlib, no new deps.
2. **In-flight secret injection** (from matchlock). Pipelock already 2. **In-flight secret injection** (from matchlock). The egress scanner
does egress allowlisting and DLP; teaching it to *inject* tokens at already does allowlisting and DLP; teaching it to *inject* tokens at
proxy time so e.g. `GITEA_TOKEN` never appears in the container's proxy time so e.g. `GITEA_TOKEN` never appears in the container's
env would close the "agent reads its own env and exfiltrates" path. env would close the "agent reads its own env and exfiltrates" path.
Fits the existing pipelock architecture. Fits the existing egress-proxy architecture.
3. **MicroVM backend as an opt-in bottle type** — already on the radar 3. **MicroVM backend**~~on the radar~~ **shipped since this survey.**
in `stronger-isolation-alternatives.md`. microsandbox, smolmachines, microVMs are now bot-bottle's default (Firecracker on KVM Linux, Apple
and matchlock all show that libkrun + Apple's Container on macOS); Docker is the legacy fallback. The libkrun / Apple
Virtualization.framework is ergonomic enough that a Virtualization.framework ergonomics that microsandbox, smolmachines,
`"runtime": "microvm"` field on a bottle is plausible without a heavy and matchlock demonstrated turned out to be enough to make it the
stack. default rather than an opt-in.
Not worth borrowing: the SDK-first programmatic API style of boxlite / Not worth borrowing: the SDK-first programmatic API style of boxlite /
microsandbox (cuts against the declarative-manifest stance), and the microsandbox (cuts against the declarative-manifest stance), and the
@@ -230,3 +469,176 @@ hosted-SaaS dashboard model of tilde.run (cuts against the
- The `superradcompany/microsandbox` URL in the original prompt - The `superradcompany/microsandbox` URL in the original prompt
redirects to `microsandbox/microsandbox`; the surveyed project is the redirects to `microsandbox/microsandbox`; the surveyed project is the
same. same.
- CubeSandbox performance/scale numbers (<60ms cold start, <5MB/instance,
2,000+ sandboxes per 96-vCPU host) are the project's own launch claims,
not independently verified here.
## Addendum 2026-07-18 — CubeSandbox and the positioning read
CubeSandbox (Tencent Cloud, Apache 2.0, ~10.4k stars, HN launch
[#47863430](https://news.ycombinator.com/item?id=47863430)) is the first
project in this survey to combine, in one open-source stack, everything
bot-bottle treated as its differentiator:
- **Egress custody (connection level)** — default-deny domain allowlist
(L7 domain/SNI filtering), instant block on unauthorized egress,
per-sandbox traffic tokens, full audit logs of destinations (eBPF
virtual switch, "CubeVS"). This matches bot-bottle's egress scanner at
the *connection level*, productized — see the one thing it does **not**
match, below.
- **Credential custody** — a vault where keys "never enter the sandbox,
model context, or logs." This is the in-flight-injection idea from
matchlock, but as a first-class feature, and it's exactly the
cross-vendor "egress audit + custody" wedge the monetization
positioning treats as the one defensible moat.
- **Isolation on par with bot-bottle's current default** — a dedicated
guest kernel per sandbox (RustVMM/KVM). bot-bottle now defaults to the
same class of boundary (Firecracker microVM / Apple Container), so this
is parity, not an edge; CubeSandbox's remaining edge is running that
per-kernel isolation multi-tenant at scale on one host.
The one axis CubeSandbox does **not** cover — and where bot-bottle stays
distinctive:
- **Content DLP on *authorized* channels.** CubeSandbox's egress control
is connection-level: it decides *whether* a destination is allowed and
logs it, and its vault keeps *injected* credentials out of the sandbox
entirely. Neither inspects the *payload* of traffic to an allowed
destination. So an agent that exfiltrates over a permitted channel —
pasting a repo's contents, an agent-derived secret, or PHI into an
allowed API/domain — is not caught by CubeSandbox. bot-bottle's own
egress DLP scanner does scan that: response + websocket content against
the resolved per-flow config, with per-bottle token redaction (see
recent egress commits). The vault
approach is arguably *stronger* for the specific case of pre-known
injected credentials (they can't leak if they were never present), but
it is not a substitute for content inspection of everything else.
**Long-running posture — a sharper axis than raw isolation.** E2B and
CubeSandbox are *ephemeral-per-task* by design; a long-running agent is an
architected pattern on top, not the default. E2B: 5-minute default
timeout, continuous runtime tier-capped (~1h Hobby / ~24h Pro), duration
achieved via **pause/resume** (preserves filesystem + memory + processes;
reconnect by sandbox ID via `Sandbox.connect()`; resume resets the timeout
to 5 min; auto-pause via `on_timeout: "pause"`). CubeSandbox mirrors this
(E2B drop-in) with first-class auto pause/resume and hundred-ms
checkpoint/fork — and, self-hosted, sets its own timeout policy with no
vendor tier caps. bot-bottle inverts the model: a bottle is **persistent,
named, and supervised by default** — long-running *is* the default, not a
session-management loop over pause/resume. smolmachines is the other
persistent-by-default project in this set. For anyone building agents that
run for hours/days, this posture difference matters more than the
isolation primitive.
**DX — the "run Claude yolo-style" bar.** The reason `claude
--dangerously-skip-permissions` is so widely used is DX: it's one command
and the agent just goes. The bottle thesis is to make a *sandboxed* run
that easy — `start <agent>` builds the image on first run and drops you
into an interactive Claude session that already has
`--dangerously-skip-permissions` on by default
(`contrib/claude/agent_provider.py`), with the sandbox as the guardrail
instead of per-action prompts. On this axis the field splits cleanly:
- **Wrappers around the agent** (as-easy-as-native): bot-bottle and
**agent-safehouse** (`safehouse claude --dangerously-skip-permissions`).
These *are* the run-Claude experience. agent-safehouse is the real DX
peer — but it's macOS-only Seatbelt, single-run, and doesn't address
network egress; bot-bottle adds VM-grade isolation, egress DLP, and
persistent/parallel bottles across macOS + Linux.
- **Libraries / services** (you build the run yourself): boxlite,
microsandbox, CubeSandbox, E2B. These hand you an SDK or a cluster and
expect you to wire the agent in — powerful for platform builders,
heavyweight for "just run Claude on my laptop." microsandbox's MCP/Skills
angle is *sandbox-as-a-tool the agent calls*, which is the inverse of
wrapping the agent.
- **In between:** litterbox (wizard + build, Linux only), smolmachines
(SSH into a named machine), matchlock (run a command in a VM).
So DX is a genuine bot-bottle differentiator, and the only project that
matches it (agent-safehouse) does so with materially weaker isolation and
no egress story. "As easy as native yolo, but actually sandboxed" is a
defensible one-liner.
Why it still doesn't collide head-on:
1. **Shape.** CubeSandbox is a *multi-tenant service for platform
builders* (drop-in E2B replacement, SDK-driven, 2,000 sandboxes on a
box). bot-bottle is a *single-operator, declarative-manifest tool for
the infrastructure I run*. Different buyer, different ergonomics — no
JSON manifest, no bottle/agent split, no "one command on my laptop."
2. **Backend, not competitor.** Like boxlite/microsandbox, CubeSandbox is
something bot-bottle could sit *on top of* — a `"runtime": "microvm"`
or `"runtime": "cubesandbox"` backend under the manifest layer — while
keeping the manifest, the bottle/agent split, and the local,
single-operator default.
Why it matters anyway:
- The "nobody else bundles connection-level egress allowlist + audit +
in-flight credential custody" line is **no longer true for the
primitive** — a well-funded, 10k-star open-source project now ships it.
But **content DLP on authorized channels is still not matched** (see
above), and neither is the *layer above* the primitive (declarative
manifest, cross-vendor orchestration, operator UX, the
phone-control/dashboard north star). Those two — outbound-payload DLP
and the orchestration layer — are where the defensible ground now sits;
the connection-level allowlist + vault mechanism, on its own, is no
longer differentiating. Revisit the monetization open/paid line with
that in mind.
- Worth a closer look at **how** CubeSandbox does credential injection
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.
@@ -0,0 +1,345 @@
# HN discourse on agent sandbox safety — June/July 2026
A survey of community opinion and notable security disclosures on Hacker
News and adjacent sources over JuneJuly 2026. The question: what does
the current discourse say about whether sandboxes are sufficient for
agentic AI safety, and where does bot-bottle land against the issues
being raised?
Research conducted 2026-07-18.
## Summary
The past month marks a turning point in community opinion. Earlier in
2026, the debate was mostly "which sandbox tool is best?" By JuneJuly,
a cascade of critical CVEs and novel attack classes has shifted the
framing to "sandboxes are not enough — what else do you need?" The
attacks that drove this shift are structurally distinct: most route
through legitimate, trusted channels (Sentry issues, MCP descriptions,
README files) rather than exploiting the isolation boundary directly.
bot-bottle's architecture holds up well against the direct-escape class
(Firecracker/Apple Container default backends, credentials never in the
agent's env, harness entirely on the host). The remaining gap is prompt
injection — attacker-controlled data interpreted as model instructions.
Egress controls and prompt injection defenses are orthogonal: egress
limits what the agent can *send out*; injection is about what it is
*told to do*. The two don't substitute for each other. Inside a tightly-
egressed sandbox a successful injection can't exfiltrate to unknown
hosts, but it can still corrupt the work product, push malicious commits
past a secret scanner, or use allowlisted channels for exfiltration.
Those residual risks are addressed below.
## The sandboxing boom sets the stage
The preceding months generated a wave of sandbox tooling. A March 28
Ask HN thread
([#47444917](https://news.ycombinator.com/item?id=47444917)) catalogued
the explosion: E2B, AIO Sandbox, AgentSphere, Yolobox, Exe.dev,
AgentFence, DenoSandbox, Capsule (WASM), ERA, Vibekit, Daytona, Modal,
Nono, and more — all launched within roughly 12 months. A parallel March
9 thread ([#47185250](https://news.ycombinator.com/item?id=47185250))
surveyed what developers were actually deploying: "containers or YOLO"
dominated. The honest community mood was that most teams hadn't solved
this and were shipping anyway.
## The JuneJuly attack cascade
Six attack patterns broke in quick succession. Together they form the
argument that the community's framing was wrong: the threat model for
agents isn't just "code that escapes its container" — it's also prompt
injection, where attacker-controlled data is interpreted as model
instructions regardless of whether any isolation boundary was crossed.
Sections 24 below are all the same attack class; the "trusted channel"
label describes the delivery vector, not a different threat.
### 1. Sandbox escape CVEs (DuneSlide, CVE-2026-39861)
Cato AI Labs disclosed **DuneSlide** (CVE-2026-50548/50549, CVSS 9.8),
a pair of flaws in Cursor 2.x. CVE-2026-50548 abuses the sandbox's
`working_directory` parameter to point writes at system files; CVE-26-50549
exploits a symlink-resolution fallback that fails open. Both start with
a prompt injection and end in sandbox escape — and Cato's framing was
blunt: "each CVE defeats a different guardrail; the problem is
structural, not a string of one-offs."
Claude Code's own sandbox had a similar escape this year:
**CVE-2026-39861** (symlink flaw). The CurXecute/MCPoison/CVE-2026-26268
chain from Cursor added a poisoned Slack message, a swap-after-approval
MCP config, and a Git hook as three more entry points in the same
attack class.
All patched, but the pattern holds: any application-level sandbox that
takes attacker-influenced values as path parameters is reachable from a
prompt injection.
### 2. Prompt injection via MCP data (Agentjacking)
Tenet's "Agentjacking" technique planted a fake bug report in Sentry's
MCP output. When an agent queries Sentry to fix open issues, the
malicious event is rendered as structured content visually
indistinguishable from a real Sentry event, and the agent executes the
embedded instructions with the developer's full privileges. Hit rate
across Claude Code and Cursor: **85%**. The route is entirely through a
legitimately-authorized MCP channel — no isolation boundary is crossed;
the injection arrives inbound through a channel the sandbox explicitly
trusts.
The Cloud Security Alliance's summary: treat observability, bug-report,
and integration data as **untrusted agent input**, not neutral
development metadata.
### 3. README-embedded prompt injection
A July disclosure showed malicious instructions hidden in `README.md`
— a file that receives no trust prompt and requires no elevated access.
When asked point-blank whether the repo held hidden instructions, both
Claude Sonnet 4.6 and GPT-5.5 said no. A payload written for Sonnet
4.6 transferred unchanged to Sonnet 5, Opus 4.8, and GPT-5.5. The
attack surface is every repo an agent is asked to work in.
### 4. Prompt injection via MCP tool descriptions
Microsoft research (June 30) showed that attacker-controlled MCP tool
description fields can silently redirect agent behavior. The injection
is embedded in metadata the model reads during tool selection — before
any sandbox enforcement or egress check runs, and entirely on the
inbound path that egress controls cannot touch.
### 5. MCP STDIO command injection (10 CVEs)
OX Security disclosed a systemic command injection class in Anthropic's
MCP protocol, covering 10 CVEs across multiple coding agents. The
Windsurf case (CVE-2026-30615): processing attacker-controlled HTML
causes the agent to auto-register a malicious MCP STDIO server and
execute arbitrary commands with no further user interaction.
### 6. LiteLLM gateway compromise (CVE-2026-40217, CVE-2026-42271)
CVE-2026-40217 exposes LiteLLM's guardrail sandbox via `exec()` with no
source filtering. CVE-2026-42271 (exploited in the wild, added to CISA's
KEV catalog) lets callers spawn subprocesses through MCP preview
endpoints. The threat extends to any agent routed through a compromised
LiteLLM proxy: the proxy can swap model responses for forged tool calls
in transit, giving the attacker a reverse shell from the developer's
machine.
## HN community opinion clusters
**"Move enforcement to the kernel, not the app"** — the Nono Show HN
([#46849615](https://news.ycombinator.com/item?id=46849615)) and a
kernel-sandbox thread
([#47066574](https://news.ycombinator.com/item?id=47066574)) both argued
that application-layer sandboxes are inherently bypassable by the code
they're sandboxing. The academic framing, from *Red-Teaming the Agentic
Red-Team* ([arXiv 2606.24496](https://arxiv.org/pdf/2606.24496)):
"enforcement should occur at the OS level via the kernel refusing system
calls that violate policy at runtime — not pre-execution argument
validation in tool calls."
**"The harness belongs outside the sandbox"** — a May thread
([#47990675](https://news.ycombinator.com/item?id=47990675)) converged
on clean architectural separation: harness in one VM, tool execution in
another. Top comment: "having the harness in one VM, and tool use applied
to user data in another, is about as safe as you can be at present."
Several replies described a hypervisor-like policy layer — sitting outside
both VMs — as the right long-term model.
**"Sandboxes are too coarse-grained"** — a Feb thread
([#47006445](https://news.ycombinator.com/item?id=47006445)) argued
that VMs don't answer the real question: knowing whether an agent
*should* be sending an email or making a transaction. "Everything's just
in the same big box." This framing picked up traction through JuneJuly
as the trusted-channel attacks dominated.
**"MCP's trust model is the real problem"** — the month's recurring
theme. MCP by design gives agents access to authorized external services.
Once a trusted channel delivers a malicious payload, filesystem sandboxing
is irrelevant. The community call: treat all MCP tool metadata and return
values as untrusted input subject to policy validation before ingestion,
and disable automatic MCP server loading from untrusted repositories.
## How bot-bottle addresses these issues
### What it covers well
**Direct sandbox escape (CVEs, container breakout)**
bot-bottle's default backends are Firecracker microVM (KVM Linux) and
Apple Container (macOS). Both run the agent in a separate VM with a
dedicated kernel — the container-escape CVE class (Dirty Pipe, runc
escapes, DuneSlide's path-parameter abuse) requires escaping a real
hypervisor boundary, not just a namespace. On the legacy Docker backend,
gVisor auto-detection provides a userspace syscall barrier for hosts where
neither KVM nor Apple Container is available.
The bot-bottle process itself runs entirely on the host, outside the VM.
This is the "harness outside the sandbox" architecture the HN thread
converged on as best practice. The bottle manifest, egress rules, and
secrets never enter the agent VM.
**Credential theft on sandbox escape**
Even on a successful VM/container escape, the agent has nothing useful
to steal. Credentials are injected in-flight by the gateway proxy
(`auth.scheme` / `auth.token_ref` in the egress route config) — `printenv`
inside the agent shows proxy URLs only. The git-gate similarly holds the
upstream SSH credential on the host; the agent pushes through a
gitleaks-scanned daemon that forwards clean refs upstream. An escaped
agent gets the host filesystem, not the keys.
**Orphaned-agent credential risk**
bot-bottle is explicitly ephemeral: when the agent exits, `cli.py` tears
down every gateway and both networks — nothing persists between runs. The
agent never holds credentials, so there is nothing to orphan.
**MCP config redirection / STDIO auto-registration**
The trust boundary at `$HOME` means bottles live only under
`~/.bot-bottle/bottles/` — a cloned repo cannot add egress routes or
redirect env vars to attacker hosts (the design rationale is in
`docs/prds/0011-per-file-md-manifest.md`). Auto-registering a malicious
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
description poisoning — the final step in most attacks is exfiltration.
bot-bottle's egress allowlist is default-deny with a per-bottle host
allowlist; unknown hosts get a hard 403. Outbound DLP scanning
(`outbound_detectors: [token_patterns, known_secrets]`) catches tokens
and secrets in outbound bodies; the `supervise` policy (default for
manifest routes) holds the request for operator approval rather than
silently blocking it. Together these limit what a successful injection
can *do* even if it succeeds at the model layer.
**LiteLLM / compromised-proxy attacks**
bot-bottle does not use LiteLLM. The model API route (e.g.
`api.anthropic.com`) is an auto-injected provider route on the egress
allowlist; the agent dials the gateway, not the model API directly.
A compromised third-party proxy is not in the architecture.
### Where it is weaker
**Prompt injection**
Egress controls and prompt injection defenses are orthogonal. Egress
limits what the agent can *send out* (outbound leg); prompt injection
is about what attacker-controlled data *tells the agent to do* (inbound
leg). The two don't substitute for each other and must be treated
separately.
The inbound DLP scanner (`inbound_detectors: [naive_injection_detection]`)
is the only runtime defense against injection arriving through allowlisted
channels — Sentry MCP responses, MCP tool descriptions, README content.
It is explicitly pattern-matching and will not catch a sufficiently
crafted payload. There is no semantic / intent-level gate between what
the model decides and what the agent executes.
**Blast radius within the permitted scope**
Inside a tightly-egressed sandbox a successful injection can't
exfiltrate to unknown hosts, but it still has real options:
- *Work product corruption.* The agent can modify, delete, or backdoor
files in the working directory. This is within its permitted scope;
egress controls have nothing to say about it.
- *Malicious commits past the git-gate.* The git-gate scans outbound
refs for secrets (gitleaks), not for semantic code intent. A prompt-
injected agent can commit subtly malicious code — logic bombs,
backdoored auth paths, code that exfiltrates data through the
application's own HTTP clients at runtime — that looks clean to a
secret scanner.
- *Exfiltration through allowlisted channels.* If an attacker knows or
can predict what hosts are in the egress allowlist, those channels are
available for exfiltration. A GitHub remote being allowlisted means
"push to an attacker-controlled fork" is viable. A logging endpoint
being allowlisted means structured data can leave through it. The
outbound DLP scanner catches credential tokens and known secrets but
not arbitrary business data.
- *Dependency installation within the sandbox.* An agent that runs
`npm install` or `pip install` on attacker-specified packages executes
code inside the sandbox with the same capabilities the agent has:
filesystem access, tool calls, calls to allowlisted hosts. Supply chain
injection via package names is in the same injection family, triggered
by the same prompt-injection path.
### What would close the remaining gaps
The blast-radius risks above point at two distinct mitigations that
don't yet exist in bot-bottle:
- *Outbound intent classification.* The egress addon today scans
outbound request content for token patterns. What it lacks is
awareness of context — it can't distinguish "agent is pushing a
legitimate commit" from "agent was injected and is pushing a backdoor."
The `supervise` policy is already the right shape for human-in-the-loop
review on sensitive outbound actions; extending it with context from
the agent's recent tool calls (what files were touched, what was the
triggering task) would narrow the gap.
- *Semantic code review on git push.* gitleaks is the wrong tool for
catching injected logic. A review step on outbound commits — even a
simple diff summary surfaced in `cli.py supervise` before the push is
forwarded — would close the malicious-commit path without requiring
the agent to be fully trusted.
## Sources
- [Ask HN: The new wave of AI agent sandboxes? (Mar 2026)](https://news.ycombinator.com/item?id=47444917)
- [OK, let's survey how everybody is sandboxing AI coding agents (Mar 2026)](https://news.ycombinator.com/item?id=47185250)
- [The agent harness belongs outside the sandbox (May 2026)](https://news.ycombinator.com/item?id=47990675)
- [Show HN: Nono Kernel-enforced sandboxing for AI agents (Feb 2026)](https://news.ycombinator.com/item?id=46849615)
- [Kernel-enforced sandbox for AI agents, MCP and LLM workloads (Feb 2026)](https://news.ycombinator.com/item?id=47066574)
- [Sandboxes will be left in 2026 (Feb 2026)](https://news.ycombinator.com/item?id=47006445)
- [Critical Cursor Flaws / DuneSlide The Hacker News](https://thehackernews.com/2026/07/critical-cursor-flaws-could-let-prompt.html)
- [Agentjacking Attack The Hacker News](https://thehackernews.com/2026/06/agentjacking-attack-tricks-ai-coding.html)
- [Friendly Fire: AI Agents Built to Catch Malicious Code The Hacker News](https://thehackernews.com/2026/07/friendly-fire-ai-agents-built-to-catch.html)
- [Microsoft Warns Poisoned MCP Tool Descriptions The Hacker News](https://thehackernews.com/2026/06/microsoft-warns-poisoned-mcp-tool.html)
- [MCP STDIO Command Injection Advisory OX Security](https://www.ox.security/blog/mcp-supply-chain-advisory-rce-vulnerabilities-across-the-ai-ecosystem/)
- [LiteLLM Vulnerability Chain The Hacker News](https://thehackernews.com/2026/06/litellm-vulnerability-chain-lets-low.html)
- [Red-Teaming the Agentic Red-Team (arXiv 2606.24496)](https://arxiv.org/pdf/2606.24496)
+204
View File
@@ -0,0 +1,204 @@
# Scanning for malicious commits at the git-gate — and whether the semantic layer is a paid feature
A sandboxed agent's most durable way out isn't live exfiltration — it's the
**artifact it pushes**. Code committed through the git-gate leaves the
sandbox and runs *later*, in CI or production, with none of the bottle's
isolation or egress control. gitleaks covers *secrets* on that channel
(see [`git-gate` pre-receive](../../bot_bottle/git_gate_render.py)); this
note asks the harder question — can we scan for *malicious code* in
commits — and the product question the answer raises: is the semantic
(LLM) layer a decent paid feature on "git egress," alongside a paid
human-review console for the supervise flow?
## Summary
- **No scanner reliably detects malicious code.** It's undecidable in
general and adversarial in practice — a capable agent hides a backdoor
from any pattern scanner (and from human reviewers). So the frame is not
"detect malice"; it's **raise the cost, cover the common/obvious cases,
and put a human on the high-leverage ones.**
- **Layered, ranked by ROI:** (1) supply-chain / malicious-dependency
scanning (Socket, OSV-Scanner, GuardDog) — highest ROI, best git fit;
(2) fast heuristic / obfuscation / dangerous-sink scanning
(Semgrep-on-diff) — gitleaks-shaped; (3) **risk-based human gating** via
the existing supervise plane on high-risk file classes; (4) **LLM
semantic diff-review** as a best-effort backstop for novel backdoors.
- **Architecture:** the git-gate's `pre-receive` is *synchronous* (it
blocks the push), so run only fast scanners inline and push heavy
analysis (CodeQL, full LLM review) to an **async post-push CI lane** that
can't hold the developer hostage.
- **Monetization verdict: yes — but the paid unit is the *governed
git-egress review bundle*, not the raw scanner.** The pre-receive
mechanics and the fast OSS scanners stay free (open-core social
contract, same as gitleaks). The sell is the control-plane workflow
around them: **managed semantic review + a web-console human-review flow
+ RBAC + audit retention + cross-run policy.** A bare per-push scan is
borderline "single-run = free" by our own heuristic, and its detection
accuracy is not something to sell on. Sell the *workflow and the
governance*, not the detector.
## The threat: temporal escape
This is the one containment fundamentally can't close. bot-bottle's VM
isolation + egress DLP shrink *live* exfiltration, but the git push
channel launders a payload past all of it: the agent commits code that
looks benign at review time and does its work later, outside the
perimeter — the "blind-XSS"-style temporal escape that both the "3 reasons
sandboxing won't secure your AI agent" talk and Embroidery's "In sandboxes
we (shouldn't) trust" call the sharpest gap. gitleaks gates *secrets* on
this channel; malicious *code* is the uncovered surface, and it's the
behavioral half of the isolation-vs-behavioral split noted in
[`agent-sandbox-landscape.md`](agent-sandbox-landscape.md).
## Why it's fundamentally hard
Distinguishing malicious from legitimate code is undecidable in general
and adversarial in the specific. The underhanded-code contests show humans
hide backdoors from *human reviewers*; a scanner reduces to regex/dataflow
heuristics a motivated agent writes around. So "catch malicious commits"
is the wrong success metric. The right ones: *how much did we raise the
cost*, *did we cover the common and the obvious*, and *did a human see the
dangerous changes before they left*.
## Landscape of git-pluggable approaches (ranked by ROI)
1. **Supply-chain / malicious-dependency scanning — highest ROI.** The
most likely agent-introduced threat is a malicious or typosquatted
*dependency*, not a hand-written backdoor — and it's far more
detectable. **Socket** (behavioral analysis of packages: install
scripts, obfuscation, unexpected network/fs access, typosquats) is the
closest thing to malicious-intent detection that actually works;
**OSV-Scanner** (known-bad DB, fast) and **GuardDog** (PyPI/npm
heuristics) complement it. Trigger only when a commit changes a
lockfile/manifest → block or escalate.
2. **Fast heuristic / obfuscation / dangerous-sink scanning.** **Semgrep**
with a curated ruleset, scanning the *diff* (fast enough for
pre-receive). Catches obfuscation (`eval(atob(...))`, base64/hex blobs,
minified source), exfil primitives (`curl | sh`, reverse shells,
DNS-exfil), and dangerous sinks (`shell=True`, `pickle.loads`, unsafe
`yaml.load`, `child_process.exec`). Tune noisy rules to *escalate*, not
block.
3. **Risk-based human gating — the most robust control, already
half-built.** The git-gate already escalates `# gitleaks:allow`
findings to a supervisor proposal and blocks the push pending approval
(`supervise_gitleaks_allow`, [`git_gate_render.py`](../../bot_bottle/git_gate_render.py)).
Extend the same flow to **high-risk file classes**: any commit touching
CI/build/deploy scripts, auth/crypto code, egress config, or
adding/changing dependencies → route to `./cli.py supervise`. This is
attribution/policy, not detection, and it's the strongest thing here —
a human on exactly the temporal-escape surfaces.
4. **LLM semantic diff-review — the behavioral backstop.** The only
approach with a shot at a novel, benign-looking backdoor: a model
reviews the diff for a backdoor / exfiltration / obfuscated payload /
dangerous behavior. This is the behavioral-detection thesis applied to
the *artifact* (the diff) rather than the agent's reasoning. Real
caveats: false positives and negatives, latency/cost, it can be
prompt-injected by hostile content *in the diff itself*, and it invites
over-trust. Useful, never a guarantee.
**Not for this job:** CodeQL, Trivy, Grype, Bandit. They find *known
vulns and insecure patterns* (bugs), not deliberate backdoors, and the
powerful ones (CodeQL taint) need a build + database — too heavy for a
synchronous gate. They belong in the async CI lane if at all.
## Fit into bot-bottle's git-gate
The `pre-receive` hook today is: gitleaks-scan each ref → escalate
`# gitleaks:allow` findings to supervise → forward to upstream
([`git_gate_render.py`](../../bot_bottle/git_gate_render.py)). The
additions slot in cleanly:
- **Inline (fast), before forward:** a dep-scan phase (on manifest/lockfile
change) and a Semgrep-diff phase. Findings block or open a supervise
proposal, same shape as gitleaks.
- **New supervise tool types** alongside the existing
`egress-block/allow`, `gitleaks-allow`, `egress-token-allow`
([`supervise_types.py`](../../bot_bottle/supervise_types.py)) — e.g. a
`commit-review` proposal for risky-file-class gating and for semantic
review. The supervise plane is already the right abstraction; this is
another *producer* feeding it, and [`supervise_server.py`](../../bot_bottle/supervise_server.py)
(JSON-RPC) is already the console backend.
- **Async lane (heavy):** full LLM review + any CodeQL run out of band
after the push, feeding the same review/audit surface, so the
synchronous gate stays fast.
## The product question: paid feature on git egress?
Restating the open-core line bot-bottle runs on: *give away the
sandbox/runtime, charge for the control plane; single-run/single-node =
free, cross-run aggregation + central enforcement + identity/fleet = paid;
the moat is uniform egress audit + secret custody + policy across
untrusted agents.*
Against that line, the split is clean:
**Free (OSS runtime — the trust funnel):**
- the `pre-receive` gate mechanics and gitleaks;
- wiring the OSS scanners (Socket CLI / OSV-Scanner / Semgrep);
- the CLI supervise flow.
Keeping the raw scanners free is the same social contract as gitleaks and
preserves the bottom-up distribution funnel.
**Paid (the governed git-egress bundle — the control plane):**
- **Managed semantic diff-review** — hosted inference + a curated,
maintained malicious-pattern/policy set. This is *capability* (metered),
not *insurance* — the thing individuals actually pay for. Position it as
**governed code-egress review**, not "we resell inference" (the
monetization notes explicitly warn against reselling compute).
- **The web-console supervise/review flow — the strongest anchor.** Turn
the CLI `./cli.py supervise` approval into a real review surface:
rendered diff + finding context, approve/reject, **who-approved audit
trail, RBAC on approvers, mobile/phone-control** (ties to the
dashboard/vault north star). This is "central enforcement +
identity/fleet = paid" almost verbatim — and it generalizes across
*every* supervise proposal (egress block/allow, gitleaks-allow,
commit-review), so it's worth building for the whole plane, with the
semantic check as one producer.
- **Cross-run governance:** fleet-wide policy for what escalates,
review-decision history/search/export, and drift alerts.
**Why it fits the moat rather than bolting on:** a git push *is* an egress
channel. A semantic review + human approval + audit on it extends the
uniform "egress audit + custody + policy across untrusted agents" wedge to
**code artifacts** — the same product, applied to the one channel gitleaks
only half-covers. That's on-moat, not a detour.
**The honest nuance (don't oversell):** a bare per-push LLM scan is
arguably *free* by the single-run heuristic, and its detection accuracy is
not defensible to charge for. The paid value is the **governance around
it** — the console, RBAC, audit retention, cross-run policy — plus the
managed capability. Sell the *review-and-approve-and-audit workflow*; let
the detector be explicitly best-effort. And per the monetization
guardrail, the "anti-corporate" free crowd must not veto these team
features: the review console + RBAC + audit *are* the monetization.
## Recommendation
1. **Land the free layer first.** Add the dep-scan and Semgrep-diff phases
to `pre-receive`, and extend supervise to risky-file-class gating —
reuses existing machinery, immediate value, stays OSS.
2. **Build the supervise web console** over `supervise_server`'s JSON-RPC
(already the Phase-1 move in the monetization path). This is the paid
anchor and it serves *all* proposal types, not just commit review.
3. **Add managed semantic diff-review as a paid producer** feeding that
console — "governed code-egress review," metered, explicitly
best-effort on detection.
4. **Don't oversell detection.** Market the workflow (review + approve +
audit) and the cross-run policy/RBAC, where the value is real and
defensible; keep the raw scanners open.
## Sources / references
- [`agent-sandbox-landscape.md`](agent-sandbox-landscape.md) — the
egress-DLP gap and isolation-vs-behavioral framing.
- Git-gate internals: [`git_gate_render.py`](../../bot_bottle/git_gate_render.py),
[`supervise_types.py`](../../bot_bottle/supervise_types.py),
[`supervise_server.py`](../../bot_bottle/supervise_server.py).
- External tools: Socket (socket.dev), OSV-Scanner (google/osv-scanner),
GuardDog (DataDog/guarddog), Semgrep (semgrep/semgrep).
- Threat framing: "3 reasons sandboxing won't secure your AI agent"
(youtube TsYDazwHJ6U); Embroidery, "In sandboxes we (shouldn't) trust."
- The authoritative monetization/positioning analysis (the open-core line,
the wedge, single-run-free/cross-run-paid) lives in the **separate
`bot-bottle-console` repo**, not this one — cited here from memory, not
linked.
+1 -1
View File
@@ -1,6 +1,6 @@
[build-system] [build-system]
requires = ["setuptools>=68"] requires = ["setuptools>=68"]
build-backend = "setuptools.backends.legacy:build" build-backend = "setuptools.build_meta"
[project] [project]
name = "bot-bottle" name = "bot-bottle"
+2 -1
View File
@@ -26,7 +26,8 @@ rm -f .coverage
echo "== unit ==" >&2 echo "== unit ==" >&2
"$PY" -m coverage run -m unittest discover -t . -s tests/unit "$PY" -m coverage run -m unittest discover -t . -s tests/unit
echo "== integration (skips without Docker) ==" >&2 echo "== integration (firecracker; skips docker tests) ==" >&2
BOT_BOTTLE_BACKEND=firecracker SKIP_DOCKER_TESTS=1 \
"$PY" -m coverage run --append -m unittest discover -t . -s tests/integration "$PY" -m coverage run --append -m unittest discover -t . -s tests/integration
echo "== combined report ==" >&2 echo "== combined report ==" >&2
+135
View File
@@ -0,0 +1,135 @@
#!/usr/bin/env python3
"""Enforce the repository's issue/PR metadata policy in Gitea Actions."""
from __future__ import annotations
import argparse
import json
import os
import re
import urllib.error
import urllib.request
from pathlib import Path
from typing import Any
ISSUE_REFERENCE = re.compile(
r"(?im)\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?|part\s+of|"
r"related\s+to|refs?|references)\s+#(\d+)\b"
)
TRIAGE_LABEL = "Status/Needs Triage"
def deliberate_issue_numbers(title: str, body: str) -> set[int]:
"""Return same-repository issue numbers referenced intentionally."""
return {int(match) for match in ISSUE_REFERENCE.findall(f"{title}\n{body}")}
class GiteaApi:
"""Small API client using the Actions-provided repository token."""
def __init__(self, api_url: str, repository: str, token: str) -> None:
self.base = f"{api_url.rstrip('/')}/repos/{repository}"
self.token = token
def request(self, method: str, path: str, payload: object | None = None) -> Any:
data = None if payload is None else json.dumps(payload).encode()
request = urllib.request.Request(
f"{self.base}{path}",
data=data,
method=method,
headers={
"Authorization": f"token {self.token}",
"Content-Type": "application/json",
},
)
with urllib.request.urlopen(request, timeout=15) as response:
if response.status == 204:
return None
return json.load(response)
def check_pull_request(event: dict[str, Any], api: GiteaApi) -> list[str]:
"""Return policy violations for a pull_request event."""
pull = event["pull_request"]
errors: list[str] = []
labels = pull.get("labels") or []
if labels:
errors.append(
"PRs must be unlabeled; put tracker metadata on the linked issue "
f"(found: {', '.join(label['name'] for label in labels)})."
)
numbers = deliberate_issue_numbers(pull.get("title", ""), pull.get("body", ""))
if not numbers:
errors.append(
"PR must reference an issue with Closes/Fixes/Resolves #N, "
"Part of #N, Related to #N, Refs #N, or References #N."
)
return errors
real_issues = 0
for number in sorted(numbers):
try:
item = api.request("GET", f"/issues/{number}")
except urllib.error.HTTPError as error:
if error.code == 404:
errors.append(f"Referenced issue #{number} does not exist.")
continue
raise
if item.get("pull_request") is not None:
errors.append(f"#{number} is a pull request, not an issue.")
else:
real_issues += 1
if not real_issues and not errors:
errors.append("PR must reference at least one real issue.")
return errors
def ensure_issue_label(event: dict[str, Any], api: GiteaApi) -> bool:
"""Apply the triage label if an issue event leaves the issue unlabeled."""
issue = event["issue"]
if issue.get("pull_request") is not None or issue.get("labels"):
return False
labels = api.request("GET", "/labels?limit=100")
triage = next((label for label in labels if label["name"] == TRIAGE_LABEL), None)
if triage is None:
raise RuntimeError(f"repository label {TRIAGE_LABEL!r} does not exist")
api.request("POST", f"/issues/{issue['number']}/labels", {"labels": [triage["id"]]})
return True
def _load_event(path: str) -> dict[str, Any]:
return json.loads(Path(path).read_text(encoding="utf-8"))
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("command", choices=("check-pr", "label-issue"))
parser.add_argument("--event", default=os.environ.get("GITHUB_EVENT_PATH"))
args = parser.parse_args()
if not args.event:
parser.error("--event or GITHUB_EVENT_PATH is required")
api = GiteaApi(
os.environ["GITHUB_API_URL"],
os.environ["GITHUB_REPOSITORY"],
os.environ["GITHUB_TOKEN"],
)
event = _load_event(args.event)
if args.command == "check-pr":
errors = check_pull_request(event, api)
if errors:
print("\n".join(f"::error::{error}" for error in errors))
return 1
print("PR tracker policy passed.")
return 0
changed = ensure_issue_label(event, api)
print(f"Applied {TRIAGE_LABEL}." if changed else "Issue already has a label.")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+23
View File
@@ -0,0 +1,23 @@
"""Resolved paths to system binaries used by subprocess-based tests.
NixOS and other non-FHS hosts don't populate ``/bin`` (there is no
``/bin/sleep``), so tests that spawn real short-lived helper processes
must resolve the binary from ``PATH`` rather than hardcoding an FHS path.
Import the resolved constant (e.g. ``SLEEP``) instead of writing
``/bin/sleep`` inline.
"""
from __future__ import annotations
import shutil
def resolve(name: str, fallback: str) -> str:
"""Absolute path to ``name`` from ``PATH``; ``fallback`` on FHS hosts
where the binary isn't on ``PATH`` but lives at a known ``/bin`` path."""
return shutil.which(name) or fallback
# Real ``sleep`` binary. ``/bin/sleep`` is absent on NixOS; resolve from
# PATH so subprocess tests run instead of erroring with FileNotFoundError.
SLEEP = resolve("sleep", "/bin/sleep")
+20
View File
@@ -2,24 +2,44 @@
from __future__ import annotations from __future__ import annotations
import os
import shutil import shutil
import subprocess import subprocess
import unittest import unittest
def docker_available() -> bool: def docker_available() -> bool:
if os.environ.get("SKIP_DOCKER_TESTS"):
return False
if shutil.which("docker") is None: if shutil.which("docker") is None:
return False return False
try:
return ( return (
subprocess.run( subprocess.run(
["docker", "info"], ["docker", "info"],
stdout=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
check=False, check=False,
timeout=5,
).returncode ).returncode
== 0 == 0
) )
except subprocess.TimeoutExpired:
return False
def skip_unless_docker(reason: str = "docker unreachable"): def skip_unless_docker(reason: str = "docker unreachable"):
return unittest.skipUnless(docker_available(), reason) return unittest.skipUnless(docker_available(), reason)
def skip_unless_docker_or_firecracker(
reason: str = "neither Docker nor Firecracker selected",
):
"""Skip a backend-agnostic test unless one supported backend can run.
Firecracker does not require the host Docker daemon. The KVM coverage job
deliberately sets ``SKIP_DOCKER_TESTS`` to exclude Docker-only integration
classes while still exercising this path.
"""
firecracker_selected = os.environ.get("BOT_BOTTLE_BACKEND") == "firecracker"
return unittest.skipUnless(firecracker_selected or docker_available(), reason)
+11 -12
View File
@@ -31,7 +31,7 @@ from pathlib import Path
from bot_bottle.backend import BottleSpec, get_bottle_backend from bot_bottle.backend import BottleSpec, get_bottle_backend
from bot_bottle.bottle_state import cleanup_state from bot_bottle.bottle_state import cleanup_state
from bot_bottle.manifest import ManifestIndex from bot_bottle.manifest import ManifestIndex
from tests._docker import skip_unless_docker from tests._docker import skip_unless_docker_or_firecracker
# Secrets planted in the bottle env as literals (agents substitute via # Secrets planted in the bottle env as literals (agents substitute via
@@ -67,13 +67,14 @@ _DUMMY_HOST_KEY = (
) )
@skip_unless_docker() @skip_unless_docker_or_firecracker()
@unittest.skipIf( @unittest.skipIf(
os.environ.get("GITEA_ACTIONS") == "true", os.environ.get("GITEA_ACTIONS") == "true"
"skipped under act_runner: egress_tls_init uses a host bind mount " and os.environ.get("BOT_BOTTLE_BACKEND") != "firecracker",
"the runner container can't see, and the network topology hides " "skipped under act_runner unless BOT_BOTTLE_BACKEND=firecracker: "
"sibling-gateway visibility — same constraint as the other " "egress_tls_init uses a host bind mount the runner container can't "
"bottle-bringup integration tests", "see, and the network topology hides sibling-gateway visibility — "
"these constraints don't apply on the self-hosted KVM runner",
) )
class TestSandboxEscape(unittest.TestCase): class TestSandboxEscape(unittest.TestCase):
"""End-to-end attacks against a real bottle. The bottle stays """End-to-end attacks against a real bottle. The bottle stays
@@ -90,11 +91,9 @@ class TestSandboxEscape(unittest.TestCase):
@classmethod @classmethod
def setUpClass(cls) -> None: def setUpClass(cls) -> None:
# Docker is always required (the agent + companion containers run under it, # Pin Docker when BOT_BOTTLE_BACKEND is unset to preserve the
# and VM backends still use it for the gateway); the # Docker-backed CI path. Firecracker uses its persistent infra VM for
# class-level @skip_unless_docker already covers that. Pin # the shared gateway and therefore does not require host Docker.
# Docker when BOT_BOTTLE_BACKEND is unset to preserve the
# Docker-backed CI path.
cls._backend_name = os.environ.get("BOT_BOTTLE_BACKEND", "docker") cls._backend_name = os.environ.get("BOT_BOTTLE_BACKEND", "docker")
# Throwaway static key for the git-gate fixture. It need not # Throwaway static key for the git-gate fixture. It need not
+1 -66
View File
@@ -9,15 +9,11 @@ import unittest
from pathlib import Path from pathlib import Path
from bot_bottle.agent_provider import ( from bot_bottle.agent_provider import (
CLAUDE_HOST_CREDENTIAL_HOSTS,
CODEX_HOST_CREDENTIAL_HOSTS, CODEX_HOST_CREDENTIAL_HOSTS,
build_agent_provision_plan, build_agent_provision_plan,
prompt_args, prompt_args,
) )
from bot_bottle.egress import ( from bot_bottle.egress import CODEX_HOST_CREDENTIAL_TOKEN_REF
CLAUDE_HOST_CREDENTIAL_TOKEN_REF,
CODEX_HOST_CREDENTIAL_TOKEN_REF,
)
def _jwt(exp: int) -> str: def _jwt(exp: int) -> str:
@@ -296,67 +292,6 @@ class TestAgentProviderRuntime(unittest.TestCase):
) )
self.assertEqual({}, plan.provisioned_env) self.assertEqual({}, plan.provisioned_env)
def test_claude_forward_host_credentials_populates_egress_route(self):
access_token = "sk-ant-oat01-test-key" # gitleaks:allow
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
home = Path(tmp) / "host-claude"
cred_dir = home / ".claude"
cred_dir.mkdir(parents=True)
(cred_dir / ".credentials.json").write_text(json.dumps({
"claudeAiOauth": {"accessToken": access_token},
}))
plan = build_agent_provision_plan(
template="claude",
dockerfile="",
state_dir=Path(tmp),
instance_name="bot-bottle-test",
prompt_file=Path(tmp) / "prompt.txt",
forward_host_credentials=True,
host_env={"HOME": str(home)},
)
self.assertEqual(1, len(plan.egress_routes))
route = plan.egress_routes[0]
self.assertIn(route.host, CLAUDE_HOST_CREDENTIAL_HOSTS)
self.assertEqual("Bearer", route.auth_scheme)
self.assertEqual(CLAUDE_HOST_CREDENTIAL_TOKEN_REF, route.token_ref)
self.assertEqual("egress-placeholder", plan.env_vars["CLAUDE_CODE_OAUTH_TOKEN"])
self.assertEqual(frozenset({"CLAUDE_CODE_OAUTH_TOKEN"}), plan.hidden_env_names)
def test_claude_forward_host_credentials_populates_provisioned_env(self):
access_token = "sk-ant-oat01-test-key" # gitleaks:allow
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
home = Path(tmp) / "host-claude"
cred_dir = home / ".claude"
cred_dir.mkdir(parents=True)
(cred_dir / ".credentials.json").write_text(json.dumps({
"claudeAiOauth": {"accessToken": access_token},
}))
plan = build_agent_provision_plan(
template="claude",
dockerfile="",
state_dir=Path(tmp),
instance_name="bot-bottle-test",
prompt_file=Path(tmp) / "prompt.txt",
forward_host_credentials=True,
host_env={"HOME": str(home)},
)
self.assertEqual(
{CLAUDE_HOST_CREDENTIAL_TOKEN_REF: access_token},
plan.provisioned_env,
)
def test_claude_without_forward_host_credentials_has_empty_provisioned_env(self):
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
plan = build_agent_provision_plan(
template="claude",
dockerfile="",
state_dir=Path(tmp),
instance_name="bot-bottle-test",
prompt_file=Path(tmp) / "prompt.txt",
forward_host_credentials=False,
)
self.assertEqual({}, plan.provisioned_env)
def test_pi_plan_writes_default_ollama_models(self): def test_pi_plan_writes_default_ollama_models(self):
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp: with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
plan = build_agent_provision_plan( plan = build_agent_provision_plan(
+12
View File
@@ -215,6 +215,18 @@ class TestDockerSetupStatus(unittest.TestCase):
with patch.object(dk.shutil, "which", return_value=None): with patch.object(dk.shutil, "which", return_value=None):
self.assertFalse(dk._daemon_reachable()) self.assertFalse(dk._daemon_reachable())
def test_daemon_reachable_true_when_daemon_responds(self):
with patch.object(dk.shutil, "which", return_value="/usr/bin/docker"), \
patch.object(dk.subprocess, "run",
return_value=subprocess.CompletedProcess([], 0)):
self.assertTrue(dk._daemon_reachable())
def test_daemon_reachable_false_on_timeout(self):
with patch.object(dk.shutil, "which", return_value="/usr/bin/docker"), \
patch.object(dk.subprocess, "run",
side_effect=subprocess.TimeoutExpired(["docker", "info"], 5)):
self.assertFalse(dk._daemon_reachable())
def test_status_reports_missing_docker(self): def test_status_reports_missing_docker(self):
with patch.object(dk.shutil, "which", return_value=None): with patch.object(dk.shutil, "which", return_value=None):
rc, out = _cap(dk.status) rc, out = _cap(dk.status)
+55
View File
@@ -95,5 +95,60 @@ class TestMainDispatch(unittest.TestCase):
self.assertEqual(130, main(["x"])) 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__": if __name__ == "__main__":
unittest.main() unittest.main()
+9
View File
@@ -57,6 +57,14 @@ class TestCmdStartSelector(unittest.TestCase):
self._bottle_picker_mock = self._bottle_picker_patch.start() self._bottle_picker_mock = self._bottle_picker_patch.start()
self._bottle_picker_mock.return_value = ["claude"] # default: one bottle selected self._bottle_picker_mock.return_value = ["claude"] # default: one bottle selected
# name_color_modal opens /dev/tty and blocks on keyboard input on
# self-hosted runners that have a real controlling terminal. Stub it
# out like the other tui pickers so tests don't wait for a keypress.
self._modal_patch = patch.object(
tui_mod, "name_color_modal", return_value=("researcher", ""),
)
self._modal_patch.start()
self._env_patch = patch.dict(os.environ, {}, clear=False) self._env_patch = patch.dict(os.environ, {}, clear=False)
self._env_patch.start() self._env_patch.start()
os.environ.pop("BOT_BOTTLE_BACKEND", None) os.environ.pop("BOT_BOTTLE_BACKEND", None)
@@ -66,6 +74,7 @@ class TestCmdStartSelector(unittest.TestCase):
self._launch_patch.stop() self._launch_patch.stop()
self._agent_picker_patch.stop() self._agent_picker_patch.stop()
self._bottle_picker_patch.stop() self._bottle_picker_patch.stop()
self._modal_patch.stop()
self._env_patch.stop() self._env_patch.stop()
# ------------------------------------------------------------------ # ------------------------------------------------------------------
-186
View File
@@ -1,186 +0,0 @@
"""Unit: host Claude auth extraction."""
from __future__ import annotations
import json
import tempfile
import unittest
from datetime import datetime, timezone
from pathlib import Path
from unittest.mock import MagicMock, patch
from bot_bottle.contrib.claude.claude_auth import (
claude_auth_path,
claude_host_access_token,
)
from bot_bottle.log import Die
def _cred_json(access_token: str, **extra: object) -> str:
payload: dict[str, object] = {"claudeAiOauth": {"accessToken": access_token, **extra}}
return json.dumps(payload)
class TestClaudeHostAccessToken(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory(prefix="bb-claude-auth.")
self.home = Path(self.tmp.name)
self.cred_dir = self.home / ".claude"
self.cred_dir.mkdir()
self.auth_path = self.cred_dir / ".credentials.json"
def tearDown(self):
self.tmp.cleanup()
def _write(self, payload: dict) -> None: # type: ignore[no-untyped-def]
self.auth_path.write_text(json.dumps(payload))
def test_auth_path_uses_home_env(self):
self.assertEqual(
self.auth_path,
claude_auth_path({"HOME": str(self.home)}),
)
# --- file-based (Linux) ---
def test_file_returns_access_token(self):
key = "sk-ant-oat01-real-key" # gitleaks:allow
self._write({"claudeAiOauth": {"accessToken": key}})
out = claude_host_access_token({"HOME": str(self.home)})
self.assertEqual(key, out)
def test_file_missing_claude_ai_oauth_dies(self):
self._write({"hasCompletedOnboarding": True})
with self.assertRaises(Die):
claude_host_access_token({"HOME": str(self.home)})
def test_file_missing_access_token_dies(self):
self._write({"claudeAiOauth": {"expiresAt": 2000000000000}})
with self.assertRaises(Die):
claude_host_access_token({"HOME": str(self.home)})
def test_file_empty_access_token_dies(self):
self._write({"claudeAiOauth": {"accessToken": ""}})
with self.assertRaises(Die):
claude_host_access_token({"HOME": str(self.home)})
def test_file_expired_token_dies(self):
# expiresAt is milliseconds; 1_000_000 ms is year 1970
self._write({
"claudeAiOauth": {"accessToken": "sk-ant-oat01-x", "expiresAt": 1_000_000}, # gitleaks:allow
})
with self.assertRaises(Die):
claude_host_access_token(
{"HOME": str(self.home)},
now=datetime(2026, 1, 1, tzinfo=timezone.utc),
)
def test_file_future_expiry_is_accepted(self):
key = "sk-ant-oat01-y" # gitleaks:allow
# 2_000_000_000_000 ms ≈ year 2033
self._write({
"claudeAiOauth": {"accessToken": key, "expiresAt": 2_000_000_000_000},
})
out = claude_host_access_token(
{"HOME": str(self.home)},
now=datetime(2026, 1, 1, tzinfo=timezone.utc),
)
self.assertEqual(key, out)
def test_file_absent_expiry_is_accepted(self):
key = "sk-ant-oat01-z" # gitleaks:allow
self._write({"claudeAiOauth": {"accessToken": key}})
out = claude_host_access_token({"HOME": str(self.home)})
self.assertEqual(key, out)
def test_file_non_json_dies(self):
self.auth_path.write_text("not json {{{")
with self.assertRaises(Die):
claude_host_access_token({"HOME": str(self.home)})
def test_file_json_array_root_dies(self):
self.auth_path.write_text("[]")
with self.assertRaises(Die):
claude_host_access_token({"HOME": str(self.home)})
def test_file_extra_fields_are_ignored(self):
key = "sk-ant-oat01-real" # gitleaks:allow
self._write({
"claudeAiOauth": {
"accessToken": key,
"refreshToken": "sk-ant-ort01-secret", # gitleaks:allow
"scopes": ["user:inference"],
"expiresAt": 2_000_000_000_000,
},
})
out = claude_host_access_token({"HOME": str(self.home)})
self.assertEqual(key, out)
# --- macOS Keychain fallback ---
def _home_without_creds(self) -> Path:
"""A home dir that has .claude/ but no .credentials.json."""
empty = self.home / "no-creds"
(empty / ".claude").mkdir(parents=True)
return empty
def _mock_keychain(self, stdout: str, returncode: int = 0) -> MagicMock:
mock = MagicMock()
mock.returncode = returncode
mock.stdout = stdout
return mock
def test_keychain_used_when_file_absent(self):
key = "sk-ant-oat01-keychain" # gitleaks:allow
home = self._home_without_creds()
with patch(
"bot_bottle.contrib.claude.claude_auth.subprocess.run",
return_value=self._mock_keychain(_cred_json(key)),
), patch(
"bot_bottle.contrib.claude.claude_auth.sys.platform", "darwin",
):
out = claude_host_access_token({"HOME": str(home)})
self.assertEqual(key, out)
def test_keychain_failure_when_file_absent_dies(self):
home = self._home_without_creds()
with patch(
"bot_bottle.contrib.claude.claude_auth.subprocess.run",
return_value=self._mock_keychain("", returncode=44),
), patch(
"bot_bottle.contrib.claude.claude_auth.sys.platform", "darwin",
):
with self.assertRaises(Die):
claude_host_access_token({"HOME": str(home)})
def test_no_file_no_keychain_on_linux_dies(self):
home = self._home_without_creds()
with patch("bot_bottle.contrib.claude.claude_auth.sys.platform", "linux"):
with self.assertRaises(Die):
claude_host_access_token({"HOME": str(home)})
def test_keychain_non_json_dies(self):
home = self._home_without_creds()
with patch(
"bot_bottle.contrib.claude.claude_auth.subprocess.run",
return_value=self._mock_keychain("not-json"),
), patch(
"bot_bottle.contrib.claude.claude_auth.sys.platform", "darwin",
):
with self.assertRaises(Die):
claude_host_access_token({"HOME": str(home)})
def test_keychain_security_not_found_dies(self):
home = self._home_without_creds()
with patch(
"bot_bottle.contrib.claude.claude_auth.subprocess.run",
side_effect=FileNotFoundError,
), patch(
"bot_bottle.contrib.claude.claude_auth.sys.platform", "darwin",
):
with self.assertRaises(Die):
claude_host_access_token({"HOME": str(home)})
if __name__ == "__main__":
unittest.main()
+58
View File
@@ -0,0 +1,58 @@
"""Unit: DbStore._connection() context manager and is_migrated()."""
from __future__ import annotations
import sqlite3
import tempfile
import unittest
from pathlib import Path
from bot_bottle.db_store import DbStore
from bot_bottle.migrations import TableMigrations
def _store(tmp: Path) -> DbStore:
migrations = TableMigrations("test", ["CREATE TABLE items (id INTEGER PRIMARY KEY)"])
return DbStore(tmp / "test.db", migrations)
class TestDbStoreIsMigrated(unittest.TestCase):
def test_returns_false_when_db_absent(self):
with tempfile.TemporaryDirectory() as d:
store = _store(Path(d))
self.assertFalse(store.is_migrated())
def test_returns_false_when_schema_versions_missing(self):
# DB file exists but has no schema_versions table → OperationalError → False.
with tempfile.TemporaryDirectory() as d:
store = _store(Path(d))
conn = sqlite3.connect(store.db_path)
conn.close()
self.assertFalse(store.is_migrated())
def test_returns_true_after_migrate(self):
with tempfile.TemporaryDirectory() as d:
store = _store(Path(d))
store.migrate()
self.assertTrue(store.is_migrated())
def test_returns_false_when_behind(self):
with tempfile.TemporaryDirectory() as d:
migrations = TableMigrations(
"test",
[
"CREATE TABLE items (id INTEGER PRIMARY KEY)",
"ALTER TABLE items ADD COLUMN name TEXT",
],
)
store = DbStore(Path(d) / "test.db", migrations)
# Apply only the first migration manually.
conn = sqlite3.connect(store.db_path)
with conn:
TableMigrations("test", [migrations.migrations[0]]).apply(conn)
conn.close()
self.assertFalse(store.is_migrated())
if __name__ == "__main__":
unittest.main()
+35
View File
@@ -0,0 +1,35 @@
"""Tests for integration-test backend selection helpers."""
from __future__ import annotations
import os
import unittest
from unittest.mock import patch
from tests._docker import skip_unless_docker_or_firecracker
class TestSkipUnlessDockerOrFirecracker(unittest.TestCase):
def test_firecracker_runs_when_docker_tests_are_disabled(self):
with patch.dict(
os.environ,
{"BOT_BOTTLE_BACKEND": "firecracker", "SKIP_DOCKER_TESTS": "1"},
clear=True,
):
decorated = skip_unless_docker_or_firecracker()(type("Case", (), {}))
self.assertFalse(getattr(decorated, "__unittest_skip__", False))
def test_non_firecracker_still_skips_when_docker_tests_are_disabled(self):
with patch.dict(
os.environ,
{"BOT_BOTTLE_BACKEND": "docker", "SKIP_DOCKER_TESTS": "1"},
clear=True,
):
decorated = skip_unless_docker_or_firecracker()(type("Case", (), {}))
self.assertTrue(getattr(decorated, "__unittest_skip__", False))
if __name__ == "__main__":
unittest.main()
+18 -7
View File
@@ -35,9 +35,12 @@ class TestNetpoolSlots(unittest.TestCase):
def test_slot_ip_math_31_pairs(self): def test_slot_ip_math_31_pairs(self):
with patch.dict(os.environ, {"BOT_BOTTLE_FC_IP_BASE": "100.64.0.0"}): with patch.dict(os.environ, {"BOT_BOTTLE_FC_IP_BASE": "100.64.0.0"}):
s0, s1 = netpool.slot(0), netpool.slot(1) s0, s1 = netpool.slot(0), netpool.slot(1)
self.assertEqual(("bbfc0", "100.64.0.0", "100.64.0.1"), # Iface names track netpool's (env-driven) prefix — the KVM CI runner
# overrides it for its isolated pool, so don't hardcode "bbfc".
pfx = netpool.IFACE_PREFIX
self.assertEqual((f"{pfx}0", "100.64.0.0", "100.64.0.1"),
(s0.iface, s0.host_ip, s0.guest_ip)) (s0.iface, s0.host_ip, s0.guest_ip))
self.assertEqual(("bbfc1", "100.64.0.2", "100.64.0.3"), self.assertEqual((f"{pfx}1", "100.64.0.2", "100.64.0.3"),
(s1.iface, s1.host_ip, s1.guest_ip)) (s1.iface, s1.host_ip, s1.guest_ip))
def test_guest_cidr_is_31(self): def test_guest_cidr_is_31(self):
@@ -180,11 +183,14 @@ class TestNetpoolOverlap(unittest.TestCase):
self.assertEqual("tailscale0", conflicts[0].dev) self.assertEqual("tailscale0", conflicts[0].dev)
def test_ignores_own_taps_and_default(self): def test_ignores_own_taps_and_default(self):
# The "own tap" route uses netpool's (env-driven) iface name, so the
# test still exercises the self-ignore path on the KVM CI runner, whose
# BOT_BOTTLE_FC_IFACE_PREFIX differs from the default.
with patch.dict(os.environ, {"BOT_BOTTLE_FC_IP_BASE": "10.243.0.0", with patch.dict(os.environ, {"BOT_BOTTLE_FC_IP_BASE": "10.243.0.0",
"BOT_BOTTLE_FC_POOL_SIZE": "8"}), \ "BOT_BOTTLE_FC_POOL_SIZE": "8"}), \
self._routes([ self._routes([
{"dst": "default", "dev": "enp4s0"}, {"dst": "default", "dev": "enp4s0"},
{"dst": "10.243.0.0/31", "dev": "bbfc0"}, {"dst": "10.243.0.0/31", "dev": netpool.slot(0).iface},
{"dst": "192.168.1.0/24", "dev": "enp4s0"}, {"dst": "192.168.1.0/24", "dev": "enp4s0"},
]): ]):
self.assertEqual([], netpool.overlapping_routes()) self.assertEqual([], netpool.overlapping_routes())
@@ -203,7 +209,7 @@ class TestNetpoolAllocation(unittest.TestCase):
# over (and here, exhaust the pool). # over (and here, exhaust the pool).
slot, lock = netpool.allocate("first") slot, lock = netpool.allocate("first")
self.addCleanup(lock.close) self.addCleanup(lock.close)
self.assertEqual("bbfc0", slot.iface) self.assertEqual(netpool.slot(0).iface, slot.iface)
with patch.object(netpool, "die", with patch.object(netpool, "die",
side_effect=SystemExit("exhausted")): side_effect=SystemExit("exhausted")):
with self.assertRaises(SystemExit): with self.assertRaises(SystemExit):
@@ -323,9 +329,14 @@ class TestNetpoolDefaultsSingleSource(unittest.TestCase):
with patch.dict(os.environ, {}, clear=True): with patch.dict(os.environ, {}, clear=True):
self.assertEqual(int(d["BOT_BOTTLE_FC_POOL_SIZE"]), netpool.pool_size()) self.assertEqual(int(d["BOT_BOTTLE_FC_POOL_SIZE"]), netpool.pool_size())
self.assertEqual(d["BOT_BOTTLE_FC_IP_BASE"], netpool.ip_base()) self.assertEqual(d["BOT_BOTTLE_FC_IP_BASE"], netpool.ip_base())
# Module constants resolve through the same shared file. # The module's *defaults* come from the same shared file. Assert the
self.assertEqual(d["BOT_BOTTLE_FC_IFACE_PREFIX"], netpool.IFACE_PREFIX) # parsed defaults (not IFACE_PREFIX/NFT_TABLE, which layer a live env
self.assertEqual(d["BOT_BOTTLE_FC_NFT_TABLE"], netpool.NFT_TABLE) # override on top — the KVM CI runner sets those for its isolated pool,
# which would otherwise mask this single-source check).
self.assertEqual(d["BOT_BOTTLE_FC_IFACE_PREFIX"],
netpool._DEFAULTS["BOT_BOTTLE_FC_IFACE_PREFIX"])
self.assertEqual(d["BOT_BOTTLE_FC_NFT_TABLE"],
netpool._DEFAULTS["BOT_BOTTLE_FC_NFT_TABLE"])
def test_env_var_overrides_the_shared_default(self): def test_env_var_overrides_the_shared_default(self):
with patch.dict(os.environ, {"BOT_BOTTLE_FC_IP_BASE": "10.99.0.0"}): with patch.dict(os.environ, {"BOT_BOTTLE_FC_IP_BASE": "10.99.0.0"}):
+4 -1
View File
@@ -63,9 +63,12 @@ class TestNetpoolProbes(unittest.TestCase):
self.assertEqual(2, ok.call_count) self.assertEqual(2, ok.call_count)
def test_missing_taps(self): def test_missing_taps(self):
# Derive the expected iface from netpool's (env-driven) config rather
# than hardcoding "bbfc1": the KVM CI runner sets BOT_BOTTLE_FC_* for
# its isolated pool, so the prefix there is not the default.
with patch.dict("os.environ", {"BOT_BOTTLE_FC_POOL_SIZE": "2"}), \ with patch.dict("os.environ", {"BOT_BOTTLE_FC_POOL_SIZE": "2"}), \
patch.object(netpool, "tap_present", side_effect=[True, False]): patch.object(netpool, "tap_present", side_effect=[True, False]):
self.assertEqual(["bbfc1"], netpool.missing_taps()) self.assertEqual([netpool.slot(1).iface], netpool.missing_taps())
def test_orch_slot_is_top_of_ip_base_16(self): def test_orch_slot_is_top_of_ip_base_16(self):
# Dedicated orchestrator link: /31 at the top of the IP_BASE /16, # Dedicated orchestrator link: /31 at the top of the IP_BASE /16,
+12 -1
View File
@@ -24,7 +24,7 @@ class TestBuildAgentRootfsDir(unittest.TestCase):
self.addCleanup(self._tmp.cleanup) self.addCleanup(self._tmp.cleanup)
def test_cache_hit_skips_rebuild(self): def test_cache_hit_skips_rebuild(self):
digest = image_builder._dockerfile_hash(self.dockerfile) digest = image_builder._rootfs_digest(self.dockerfile)
base = self.cache / "rootfs" / f"agent-{digest}" base = self.cache / "rootfs" / f"agent-{digest}"
base.mkdir(parents=True) base.mkdir(parents=True)
(base / ".bb-ready").write_text("ok\n") (base / ".bb-ready").write_text("ok\n")
@@ -55,6 +55,17 @@ class TestBuildAgentRootfsDir(unittest.TestCase):
image_builder._dockerfile_hash(other), image_builder._dockerfile_hash(other),
) )
def test_rootfs_digest_tracks_dockerfile_and_init(self):
# Same Dockerfile, different injected init -> different rootfs key, so
# an init fix (e.g. /tmp perms) rebuilds instead of reusing a stale
# rootfs; different Dockerfiles also differ.
base = image_builder._rootfs_digest(self.dockerfile)
with patch.object(image_builder.util, "_GUEST_INIT", "#!/bin/sh\n# changed\n"):
self.assertNotEqual(base, image_builder._rootfs_digest(self.dockerfile))
other = self.cache / "Dockerfile2"
other.write_text("FROM python:3.12-slim\n")
self.assertNotEqual(base, image_builder._rootfs_digest(other))
class TestSmokeTest(unittest.TestCase): class TestSmokeTest(unittest.TestCase):
def test_empty_argv_is_noop(self): def test_empty_argv_is_noop(self):
+111 -9
View File
@@ -38,7 +38,9 @@ class TestBuildInfraRootfs(unittest.TestCase):
# and exports PATH so gateway_init's subprocess daemons find python3. # and exports PATH so gateway_init's subprocess daemons find python3.
init = build.call_args.kwargs["init_script"] init = build.call_args.kwargs["init_script"]
self.assertIn("bot_bottle.orchestrator", init) self.assertIn("bot_bottle.orchestrator", init)
self.assertIn("gateway_init.py", init) # Gateway launches via the installed package (there is no
# /app/gateway_init.py file since the daemons moved into bot_bottle).
self.assertIn("bot_bottle.gateway_init", init)
self.assertIn("export PATH=", init) self.assertIn("export PATH=", init)
# Persistent registry volume mounted at the DB dir before the CP starts. # Persistent registry volume mounted at the DB dir before the CP starts.
self.assertIn("/dev/vdb", init) self.assertIn("/dev/vdb", init)
@@ -138,20 +140,51 @@ class TestWaitForHealth(unittest.TestCase):
class TestEnsureRunningSingleton(unittest.TestCase): class TestEnsureRunningSingleton(unittest.TestCase):
def test_adopts_when_healthy(self): def test_adopts_when_healthy_and_version_matches(self):
# A healthy control plane + existing key -> adopt (no boot), vm=None. # Healthy control plane + existing key + matching version marker
with patch.object(infra_vm, "_health_ok", return_value=True), \ # -> adopt (no boot), vm=None.
patch.object(infra_vm, "_infra_dir") as d, \ import tempfile
with tempfile.TemporaryDirectory() as td:
d = Path(td)
(d / "id_ed25519").write_text("k")
(d / "booted-version").write_text("v-current\n")
with patch.object(infra_vm, "_infra_dir", return_value=d), \
patch.object(infra_vm, "_expected_version", return_value="v-current"), \
patch.object(infra_vm, "_health_ok", return_value=True), \
patch.object(infra_vm, "boot") as boot: patch.object(infra_vm, "boot") as boot:
keydir = MagicMock()
(keydir / "id_ed25519").exists.return_value = True
d.return_value = keydir
infra = infra_vm.ensure_running() infra = infra_vm.ensure_running()
boot.assert_not_called() boot.assert_not_called()
self.assertIsNone(infra.vm) self.assertIsNone(infra.vm)
def test_reboots_when_version_stale(self):
# Healthy control plane but the running VM booted an OLDER image
# (marker mismatch) -> reboot rather than adopt stale code.
import tempfile
with tempfile.TemporaryDirectory() as td:
d = Path(td)
(d / "id_ed25519").write_text("k")
(d / "booted-version").write_text("v-old\n")
with patch.object(infra_vm, "_infra_dir", return_value=d), \
patch.object(infra_vm, "_expected_version", return_value="v-current"), \
patch.object(infra_vm, "_health_ok", return_value=True), \
patch.object(infra_vm, "stop") as stop, \
patch.object(infra_vm, "ensure_built"), \
patch.object(infra_vm, "wait_for_health"), \
patch.object(infra_vm, "boot") as boot:
boot.return_value = infra_vm.InfraVm(
guest_ip="10.243.255.1", private_key=Path("/k"), vm=MagicMock())
infra_vm.ensure_running()
stop.assert_called_once() # dislodge the outdated VM
boot.assert_called_once()
# The fresh boot records the current version for the next launcher.
self.assertEqual("v-current\n", (d / "booted-version").read_text())
def test_boots_when_unhealthy(self): def test_boots_when_unhealthy(self):
with patch.object(infra_vm, "_health_ok", return_value=False), \ import tempfile
with tempfile.TemporaryDirectory() as td:
with patch.object(infra_vm, "_infra_dir", return_value=Path(td)), \
patch.object(infra_vm, "_expected_version", return_value="v-current"), \
patch.object(infra_vm, "_health_ok", return_value=False), \
patch.object(infra_vm, "stop") as stop, \ patch.object(infra_vm, "stop") as stop, \
patch.object(infra_vm, "ensure_built") as built, \ patch.object(infra_vm, "ensure_built") as built, \
patch.object(infra_vm, "boot") as boot, \ patch.object(infra_vm, "boot") as boot, \
@@ -185,5 +218,74 @@ class TestKillPidfile(unittest.TestCase):
kill.assert_not_called() kill.assert_not_called()
class TestAdoptable(unittest.TestCase):
def _dir(self, td: str, *, key: bool = True, version: str | None = None) -> Path:
d = Path(td)
if key:
(d / "id_ed25519").write_text("k")
if version is not None:
(d / "booted-version").write_text(version + "\n")
return d
def test_true_when_key_version_and_health(self):
import tempfile
with tempfile.TemporaryDirectory() as td:
d = self._dir(td, version="v1")
with patch.object(infra_vm, "_infra_dir", return_value=d), \
patch.object(infra_vm, "_health_ok", return_value=True):
self.assertTrue(infra_vm._adoptable(d / "id_ed25519", "u", "v1"))
def test_false_when_key_missing(self):
import tempfile
with tempfile.TemporaryDirectory() as td:
d = self._dir(td, key=False, version="v1")
with patch.object(infra_vm, "_infra_dir", return_value=d):
self.assertFalse(infra_vm._adoptable(d / "id_ed25519", "u", "v1"))
def test_false_when_no_version_marker(self):
import tempfile
with tempfile.TemporaryDirectory() as td:
d = self._dir(td) # key present, no booted-version
with patch.object(infra_vm, "_infra_dir", return_value=d):
self.assertFalse(infra_vm._adoptable(d / "id_ed25519", "u", "v1"))
def test_false_when_version_mismatch(self):
import tempfile
with tempfile.TemporaryDirectory() as td:
d = self._dir(td, version="v-old")
with patch.object(infra_vm, "_infra_dir", return_value=d), \
patch.object(infra_vm, "_health_ok", return_value=True):
self.assertFalse(infra_vm._adoptable(d / "id_ed25519", "u", "v1"))
class TestKillInfraFirecrackers(unittest.TestCase):
def _fake_proc(self, root: Path, pid: int, comm: str, cmdline: list[str]) -> None:
p = root / str(pid)
p.mkdir()
(p / "comm").write_text(comm + "\n")
(p / "cmdline").write_bytes(b"\0".join(a.encode() for a in cmdline) + b"\0")
def test_kills_only_matching_infra_firecracker(self):
import tempfile
with tempfile.TemporaryDirectory() as td, \
tempfile.TemporaryDirectory() as proc:
infra_dir = Path(td)
cfg = str(infra_dir / "config.json")
root = Path(proc)
# target: firecracker bound to the infra config -> killed
self._fake_proc(root, 111, "firecracker",
["firecracker", "--no-api", "--config-file", cfg])
# a firecracker for a different (interactive) VM -> spared
self._fake_proc(root, 222, "firecracker",
["firecracker", "--config-file", "/home/u/other.json"])
# a non-firecracker process on the same config path -> spared
self._fake_proc(root, 333, "python3", ["python3", cfg])
(root / "not-a-pid").mkdir()
with patch.object(infra_vm, "_infra_dir", return_value=infra_dir), \
patch.object(infra_vm.os, "kill") as kill:
infra_vm._kill_infra_firecrackers(proc_root=root)
kill.assert_called_once_with(111, infra_vm.signal.SIGKILL)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+23 -22
View File
@@ -2,9 +2,9 @@
Tests both the helper functions in `bot_bottle.gateway_init` Tests both the helper functions in `bot_bottle.gateway_init`
and the supervisor's end-to-end signal / exit-code behavior. The and the supervisor's end-to-end signal / exit-code behavior. The
end-to-end tests use real subprocesses (`/bin/sleep`, end-to-end tests use real subprocesses (`sleep`, `/bin/sh -c '...'`)
`/bin/sh -c '...'`) short-lived, no docker required so they short-lived, no docker required so they run under `tests/unit/`
run under `tests/unit/` rather than `tests/integration/`.""" rather than `tests/integration/`."""
from __future__ import annotations from __future__ import annotations
@@ -25,6 +25,7 @@ from bot_bottle.gateway_init import (
_env_for_daemon, _env_for_daemon,
_selected_daemons, _selected_daemons,
) )
from tests._bin import SLEEP
class TestEnvForDaemon(unittest.TestCase): class TestEnvForDaemon(unittest.TestCase):
@@ -182,7 +183,7 @@ class TestSupervisor(unittest.TestCase):
# up and the supervisor never set shutdown_at. # up and the supervisor never set shutdown_at.
specs = [ specs = [
_DaemonSpec("crasher", ("/bin/sh", "-c", "exit 1")), _DaemonSpec("crasher", ("/bin/sh", "-c", "exit 1")),
_DaemonSpec("longrun", ("/bin/sleep", "30")), _DaemonSpec("longrun", (SLEEP, "30")),
] ]
sup = _Supervisor(specs) sup = _Supervisor(specs)
sup.start_all() sup.start_all()
@@ -214,7 +215,7 @@ class TestSupervisor(unittest.TestCase):
# signal-killed longrun's negative returncode. # signal-killed longrun's negative returncode.
specs = [ specs = [
_DaemonSpec("crasher", ("/bin/sh", "-c", "exit 1")), _DaemonSpec("crasher", ("/bin/sh", "-c", "exit 1")),
_DaemonSpec("longrun", ("/bin/sleep", "30")), _DaemonSpec("longrun", (SLEEP, "30")),
] ]
sup = _Supervisor(specs) sup = _Supervisor(specs)
sup.start_all() sup.start_all()
@@ -259,7 +260,7 @@ class TestSupervisor(unittest.TestCase):
) )
specs = [ specs = [
_DaemonSpec("egress", sighup_marker), _DaemonSpec("egress", sighup_marker),
_DaemonSpec("other", ("/bin/sleep", "30")), _DaemonSpec("other", (SLEEP, "30")),
] ]
sup = _Supervisor(specs) sup = _Supervisor(specs)
sup.start_all() sup.start_all()
@@ -282,7 +283,7 @@ class TestSupervisor(unittest.TestCase):
self._drive(sup) self._drive(sup)
def test_forward_signal_unknown_daemon_no_op(self): def test_forward_signal_unknown_daemon_no_op(self):
specs = [_DaemonSpec("a", ("/bin/sleep", "30"))] specs = [_DaemonSpec("a", (SLEEP, "30"))]
sup = _Supervisor(specs) sup = _Supervisor(specs)
sup.start_all() sup.start_all()
delivered = sup.forward_signal(signal.SIGHUP, "ghost") delivered = sup.forward_signal(signal.SIGHUP, "ghost")
@@ -294,8 +295,8 @@ class TestSupervisor(unittest.TestCase):
# Restart one daemon; the other (supervise, the MCP server # Restart one daemon; the other (supervise, the MCP server
# in production) must remain untouched. # in production) must remain untouched.
specs = [ specs = [
_DaemonSpec("git-gate", ("/bin/sleep", "30")), _DaemonSpec("git-gate", (SLEEP, "30")),
_DaemonSpec("supervise", ("/bin/sleep", "30")), _DaemonSpec("supervise", (SLEEP, "30")),
] ]
sup = _Supervisor(specs) sup = _Supervisor(specs)
sup.start_all() sup.start_all()
@@ -319,8 +320,8 @@ class TestSupervisor(unittest.TestCase):
def test_request_restart_is_drained_by_tick(self): def test_request_restart_is_drained_by_tick(self):
specs = [ specs = [
_DaemonSpec("git-gate", ("/bin/sleep", "30")), _DaemonSpec("git-gate", (SLEEP, "30")),
_DaemonSpec("supervise", ("/bin/sleep", "30")), _DaemonSpec("supervise", (SLEEP, "30")),
] ]
sup = _Supervisor(specs) sup = _Supervisor(specs)
sup.start_all() sup.start_all()
@@ -343,7 +344,7 @@ class TestSupervisor(unittest.TestCase):
self._drive(sup) self._drive(sup)
def test_repeated_restart_requests_coalesce(self): def test_repeated_restart_requests_coalesce(self):
specs = [_DaemonSpec("git-gate", ("/bin/sleep", "30"))] specs = [_DaemonSpec("git-gate", (SLEEP, "30"))]
sup = _Supervisor(specs) sup = _Supervisor(specs)
sup.start_all() sup.start_all()
time.sleep(0.1) time.sleep(0.1)
@@ -366,7 +367,7 @@ class TestSupervisor(unittest.TestCase):
self._drive(sup) self._drive(sup)
def test_request_restart_unknown_daemon_no_op(self): def test_request_restart_unknown_daemon_no_op(self):
specs = [_DaemonSpec("a", ("/bin/sleep", "30"))] specs = [_DaemonSpec("a", (SLEEP, "30"))]
sup = _Supervisor(specs) sup = _Supervisor(specs)
sup.start_all() sup.start_all()
ok = sup.request_restart("ghost") ok = sup.request_restart("ghost")
@@ -376,7 +377,7 @@ class TestSupervisor(unittest.TestCase):
self._drive(sup) self._drive(sup)
def test_restart_unknown_daemon_no_op(self): def test_restart_unknown_daemon_no_op(self):
specs = [_DaemonSpec("a", ("/bin/sleep", "30"))] specs = [_DaemonSpec("a", (SLEEP, "30"))]
sup = _Supervisor(specs) sup = _Supervisor(specs)
sup.start_all() sup.start_all()
ok = sup.restart_daemon("ghost") ok = sup.restart_daemon("ghost")
@@ -385,7 +386,7 @@ class TestSupervisor(unittest.TestCase):
self._drive(sup) self._drive(sup)
def test_restart_during_shutdown_is_no_op(self): def test_restart_during_shutdown_is_no_op(self):
specs = [_DaemonSpec("git-gate", ("/bin/sleep", "30"))] specs = [_DaemonSpec("git-gate", (SLEEP, "30"))]
sup = _Supervisor(specs) sup = _Supervisor(specs)
sup.start_all() sup.start_all()
sup.request_shutdown(reason="test") sup.request_shutdown(reason="test")
@@ -395,7 +396,7 @@ class TestSupervisor(unittest.TestCase):
self._drive(sup) self._drive(sup)
def test_pending_restart_dropped_during_shutdown(self): def test_pending_restart_dropped_during_shutdown(self):
specs = [_DaemonSpec("git-gate", ("/bin/sleep", "30"))] specs = [_DaemonSpec("git-gate", (SLEEP, "30"))]
sup = _Supervisor(specs) sup = _Supervisor(specs)
sup.start_all() sup.start_all()
time.sleep(0.1) time.sleep(0.1)
@@ -413,8 +414,8 @@ class TestSupervisor(unittest.TestCase):
# both should receive SIGTERM and exit. Signal-only # both should receive SIGTERM and exit. Signal-only
# shutdown clamps to a zero supervisor exit code. # shutdown clamps to a zero supervisor exit code.
specs = [ specs = [
_DaemonSpec("a", ("/bin/sleep", "60")), _DaemonSpec("a", (SLEEP, "60")),
_DaemonSpec("b", ("/bin/sleep", "60")), _DaemonSpec("b", (SLEEP, "60")),
] ]
sup = _Supervisor(specs) sup = _Supervisor(specs)
sup.start_all() sup.start_all()
@@ -449,7 +450,7 @@ class TestSupervisor(unittest.TestCase):
self.assertEqual(0, rc) self.assertEqual(0, rc)
def test_idempotent_shutdown_requests(self): def test_idempotent_shutdown_requests(self):
specs = [_DaemonSpec("a", ("/bin/sleep", "60"))] specs = [_DaemonSpec("a", (SLEEP, "60"))]
sup = _Supervisor(specs) sup = _Supervisor(specs)
sup.start_all() sup.start_all()
time.sleep(0.1) time.sleep(0.1)
@@ -470,7 +471,7 @@ class TestMainEndToEnd(unittest.TestCase):
@classmethod @classmethod
def setUpClass(cls): def setUpClass(cls):
for p in ("/bin/sh", "/bin/sleep"): for p in ("/bin/sh", SLEEP):
if not Path(p).exists(): if not Path(p).exists():
raise unittest.SkipTest(f"missing {p}") raise unittest.SkipTest(f"missing {p}")
@@ -485,8 +486,8 @@ class TestMainEndToEnd(unittest.TestCase):
"import os, runpy, sys\n" "import os, runpy, sys\n"
"from bot_bottle import gateway_init as si\n" "from bot_bottle import gateway_init as si\n"
"si._DAEMONS = (\n" "si._DAEMONS = (\n"
" si._DaemonSpec('alpha', ('/bin/sleep','30')),\n" f" si._DaemonSpec('alpha', ({SLEEP!r},'30')),\n"
" si._DaemonSpec('beta', ('/bin/sleep','30')),\n" f" si._DaemonSpec('beta', ({SLEEP!r},'30')),\n"
")\n" ")\n"
"sys.exit(si.main([]))\n" "sys.exit(si.main([]))\n"
) )
+9 -7
View File
@@ -168,16 +168,18 @@ class TestHookRender(unittest.TestCase):
# Stdin is buffered to a tempfile so both phases can re-read. # Stdin is buffered to a tempfile so both phases can re-read.
self.assertIn("refs_file=$(mktemp)", hook) self.assertIn("refs_file=$(mktemp)", hook)
def test_new_ref_scan_scoped_to_incoming_commits(self): def test_scan_scoped_to_incoming_commits(self):
# A new branch (old=all-zeros) must scan only commits new to the # Every non-delete push scans only commits new to the gate, not
# gate, not the full ancestry — otherwise historical findings # the full ancestry and not the `$old..$new` delta — otherwise
# block every new-branch push (PRD 0028 / issue #106). # historical fixtures block new-branch pushes (PRD 0028 / #106)
# and a rebase/force-push onto an advanced main drags in main's
# history incl. the sandbox-escape fixtures (#346).
hook = git_gate_render_hook() hook = git_gate_render_hook()
self.assertIn('log_opts="$new --not --all"', hook) self.assertIn('log_opts="$new --not --all"', hook)
# The old over-broad full-ancestry range must be gone. # Neither the full-ancestry range nor the ancestry-blind delta
# range may survive.
self.assertNotIn('log_opts="$new"', hook) self.assertNotIn('log_opts="$new"', hook)
# Existing-branch delta scan is unchanged. self.assertNotIn('log_opts="$old..$new"', hook)
self.assertIn('log_opts="$old..$new"', hook)
def test_forward_ssh_is_non_interactive_and_bounded(self): def test_forward_ssh_is_non_interactive_and_bounded(self):
# No prompt (BatchMode) and a connect timeout, so an unreachable # No prompt (BatchMode) and a connect timeout, so an unreachable
+1 -9
View File
@@ -80,19 +80,11 @@ class TestAgentProviderHostCredentials(unittest.TestCase):
"forward_host_credentials": "yes", "forward_host_credentials": "yes",
}) })
def test_forward_host_credentials_allowed_for_claude(self): def test_forward_host_credentials_rejected_for_claude(self):
b = _provider_config_bottle({
"template": "claude",
"forward_host_credentials": True,
})
self.assertTrue(b.agent_provider.forward_host_credentials)
def test_forward_host_credentials_and_auth_token_rejected_together(self):
with self.assertRaises(ManifestError): with self.assertRaises(ManifestError):
_provider_config_bottle({ _provider_config_bottle({
"template": "claude", "template": "claude",
"forward_host_credentials": True, "forward_host_credentials": True,
"auth_token": "SOME_TOKEN",
}) })
def test_auth_token_defaults_empty(self): def test_auth_token_defaults_empty(self):
+1 -13
View File
@@ -86,23 +86,11 @@ class TestAgentProviderValidation(unittest.TestCase):
"b", {"forward_host_credentials": True, "template": "weird"} "b", {"forward_host_credentials": True, "template": "weird"}
) )
def test_forward_creds_pi_template_rejected(self) -> None: def test_forward_creds_non_codex_template(self) -> None:
with self.assertRaises(ManifestError): with self.assertRaises(ManifestError):
ManifestAgentProvider.from_dict( ManifestAgentProvider.from_dict(
"b", {"forward_host_credentials": True, "template": "pi"}
)
def test_forward_creds_claude_allowed(self) -> None:
p = ManifestAgentProvider.from_dict(
"b", {"forward_host_credentials": True, "template": "claude"} "b", {"forward_host_credentials": True, "template": "claude"}
) )
self.assertTrue(p.forward_host_credentials)
def test_forward_creds_and_auth_token_rejected(self) -> None:
with self.assertRaises(ManifestError):
ManifestAgentProvider.from_dict(
"b", {"forward_host_credentials": True, "auth_token": "T", "template": "claude"}
)
def test_valid_claude_auth_token(self) -> None: def test_valid_claude_auth_token(self) -> None:
p = ManifestAgentProvider.from_dict("b", {"template": "claude", "auth_token": "T"}) p = ManifestAgentProvider.from_dict("b", {"template": "claude", "auth_token": "T"})
+129 -1
View File
@@ -32,7 +32,9 @@ from bot_bottle.supervise_server import (
_RpcError, _RpcError,
_RpcInternalError, _RpcInternalError,
_response_timeout_from_env, _response_timeout_from_env,
format_pending_response_text,
format_response_text, format_response_text,
handle_check_proposal,
handle_initialize, handle_initialize,
handle_tools_call, handle_tools_call,
handle_tools_list, handle_tools_list,
@@ -218,6 +220,7 @@ class TestHandleToolsList(unittest.TestCase):
_sv.TOOL_EGRESS_ALLOW, _sv.TOOL_EGRESS_ALLOW,
_sv.TOOL_EGRESS_BLOCK, _sv.TOOL_EGRESS_BLOCK,
_sv.TOOL_LIST_EGRESS_ROUTES, _sv.TOOL_LIST_EGRESS_ROUTES,
_sv.TOOL_CHECK_PROPOSAL,
]), ]),
sorted(names), sorted(names),
) )
@@ -484,9 +487,10 @@ class TestFormatResponseText(unittest.TestCase):
class TestFormatPendingResponseText(unittest.TestCase): class TestFormatPendingResponseText(unittest.TestCase):
def test_formats_timeout_message(self): def test_formats_timeout_message(self):
text = supervise_server.format_pending_response_text(12.5) text = supervise_server.format_pending_response_text("prop-9", 12.5)
self.assertIn("status: pending", text) self.assertIn("status: pending", text)
self.assertIn("12.5s", text) self.assertIn("12.5s", text)
self.assertIn("proposal_id: prop-9", text)
# --- End-to-end HTTP sanity ------------------------------------------------ # --- End-to-end HTTP sanity ------------------------------------------------
@@ -685,5 +689,129 @@ class TestResolvedRoutesPayload(unittest.TestCase):
_handler(None)._resolved_routes_payload() _handler(None)._resolved_routes_payload()
class TestNonBlockingSupervise(unittest.TestCase):
"""PRD prd-new / issue #412: pending responses carry the proposal id, and
`check-proposal` polls a queued proposal without blocking or re-proposing."""
_ROUTES = "routes:\n - host: example.com\n"
def setUp(self):
self._tmp = tempfile.TemporaryDirectory(prefix="supervise-nonblock-test.")
self._home_patch = use_bottle_root(Path(self._tmp.name) / ".bot-bottle")
self.config = ServerConfig(bottle_slug="dev")
_qs.QueueStore("dev").migrate()
_as.AuditStore().migrate()
def tearDown(self):
self._home_patch()
self._tmp.cleanup()
def _seed_proposal(self) -> "_sv.Proposal":
p = _sv.Proposal.new(
bottle_slug="dev",
tool=_sv.TOOL_EGRESS_ALLOW,
proposed_file=self._ROUTES,
justification="need example.com",
current_file_hash=_sv.sha256_hex(self._ROUTES),
)
_sv.write_proposal(p)
return p
def _check(self, proposal_id: str) -> dict[str, object]:
return handle_check_proposal({"arguments": {"proposal_id": proposal_id}}, self.config)
# --- pending response carries the id ---
def test_pending_text_includes_id_and_pointer(self):
text = format_pending_response_text("abc-123", 30.0)
self.assertIn("status: pending", text)
self.assertIn("proposal_id: abc-123", text)
self.assertIn("check-proposal", text)
def test_tools_call_timeout_returns_pending_with_id_and_stays_queued(self):
# No responder → the grace window expires → pending, not blocked forever.
result = handle_tools_call(
{
"name": _sv.TOOL_EGRESS_ALLOW,
"arguments": {"routes_yaml": self._ROUTES, "justification": "x"},
},
ServerConfig(bottle_slug="dev", response_timeout_seconds=0.05),
)
self.assertFalse(result["isError"]) # type: ignore[index]
text = result["content"][0]["text"] # type: ignore[index]
self.assertIn("status: pending", text)
pending = _sv.list_pending_proposals("dev")
self.assertEqual(1, len(pending)) # still queued, not archived
self.assertIn(pending[0].id, text) # agent got the id to poll
# --- check-proposal poll ---
def test_check_returns_approved_and_archives(self):
p = self._seed_proposal()
_sv.write_response("dev", _sv.Response(proposal_id=p.id, status=_sv.STATUS_APPROVED, notes="ok"))
result = self._check(p.id)
self.assertFalse(result["isError"])
text = result["content"][0]["text"] # type: ignore[index]
self.assertIn("status: approved", text)
self.assertIn("notes: ok", text)
with self.assertRaises(FileNotFoundError): # archived on read
_sv.read_proposal("dev", p.id)
def test_check_rejected_sets_isError(self):
p = self._seed_proposal()
_sv.write_response("dev", _sv.Response(proposal_id=p.id, status=_sv.STATUS_REJECTED, notes="no"))
result = self._check(p.id)
self.assertTrue(result["isError"])
self.assertIn("status: rejected", result["content"][0]["text"]) # type: ignore[index]
def test_check_pending_when_no_decision_yet(self):
p = self._seed_proposal()
result = self._check(p.id)
self.assertFalse(result["isError"])
text = result["content"][0]["text"] # type: ignore[index]
self.assertIn("status: pending", text)
self.assertIn(p.id, text)
self.assertEqual(1, len(_sv.list_pending_proposals("dev"))) # not archived
def test_check_unknown_id_is_error(self):
result = self._check("no-such-proposal")
self.assertTrue(result["isError"])
self.assertIn("status: unknown", result["content"][0]["text"]) # type: ignore[index]
def test_check_missing_id_raises(self):
with self.assertRaises(_RpcClientError) as cm:
handle_check_proposal({"arguments": {}}, self.config)
self.assertEqual(ERR_INVALID_PARAMS, cm.exception.code)
def test_check_empty_id_raises(self):
with self.assertRaises(_RpcClientError) as cm:
handle_check_proposal({"arguments": {"proposal_id": " "}}, self.config)
self.assertEqual(ERR_INVALID_PARAMS, cm.exception.code)
def test_check_arguments_must_be_object(self):
with self.assertRaises(_RpcClientError) as cm:
handle_check_proposal({"arguments": []}, self.config)
self.assertEqual(ERR_INVALID_PARAMS, cm.exception.code)
def test_full_nonblocking_round_trip(self):
# 1. tools/call times out → pending with id
result = handle_tools_call(
{
"name": _sv.TOOL_EGRESS_ALLOW,
"arguments": {"routes_yaml": self._ROUTES, "justification": "x"},
},
ServerConfig(bottle_slug="dev", response_timeout_seconds=0.05),
)
pid = _sv.list_pending_proposals("dev")[0].id
self.assertIn(pid, result["content"][0]["text"]) # type: ignore[index]
# 2. operator decides out-of-band
_sv.write_response("dev", _sv.Response(proposal_id=pid, status=_sv.STATUS_APPROVED, notes="ok"))
# 3. agent resumes by polling — no re-proposing
poll = self._check(pid)
self.assertFalse(poll["isError"])
self.assertIn("status: approved", poll["content"][0]["text"]) # type: ignore[index]
self.assertEqual([], _sv.list_pending_proposals("dev")) # resolved + archived
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+62
View File
@@ -0,0 +1,62 @@
import unittest
from unittest.mock import Mock
from scripts.tracker_policy import (
TRIAGE_LABEL,
check_pull_request,
deliberate_issue_numbers,
ensure_issue_label,
)
class TestDeliberateIssueNumbers(unittest.TestCase):
def test_accepts_completing_and_noncompleting_forms(self):
self.assertEqual(
deliberate_issue_numbers("Fixes #12", "Part of #14; refs #15"),
{12, 14, 15},
)
def test_does_not_treat_incidental_number_as_link(self):
self.assertEqual(deliberate_issue_numbers("Audit #12", "See PR #14"), set())
class TestCheckPullRequest(unittest.TestCase):
def test_accepts_unlabelled_pr_linked_to_real_issue(self):
api = Mock()
api.request.return_value = {"number": 12, "pull_request": None}
event = {"pull_request": {"title": "Change", "body": "Part of #12", "labels": []}}
self.assertEqual(check_pull_request(event, api), [])
def test_rejects_labels_and_pr_reference(self):
api = Mock()
api.request.return_value = {"number": 12, "pull_request": {}}
event = {
"pull_request": {
"title": "Change",
"body": "Closes #12",
"labels": [{"name": "Kind/Bug"}],
}
}
errors = check_pull_request(event, api)
self.assertEqual(len(errors), 2)
self.assertIn("unlabeled", errors[0])
self.assertIn("not an issue", errors[1])
class TestEnsureIssueLabel(unittest.TestCase):
def test_adds_triage_label_to_unlabelled_issue(self):
api = Mock()
api.request.side_effect = [[{"id": 55, "name": TRIAGE_LABEL}], None]
event = {"issue": {"number": 405, "labels": [], "pull_request": None}}
self.assertTrue(ensure_issue_label(event, api))
api.request.assert_any_call("POST", "/issues/405/labels", {"labels": [55]})
def test_leaves_labelled_issue_unchanged(self):
api = Mock()
event = {"issue": {"number": 405, "labels": [{"name": "Kind/Documentation"}]}}
self.assertFalse(ensure_issue_label(event, api))
api.request.assert_not_called()
if __name__ == "__main__":
unittest.main()