15f0e0b507
Sandbox-escape couldn't run: its git-gate fixture had no host_key, so the preflight ssh-keyscanned the deliberately-unreachable upstream and die()d in setUpClass. Preset a throwaway host_key so the keyscan is skipped (the key is never used — the push is rejected by gitleaks first). That unblocked a second, pre-existing issue: the planted secrets weren't caught by gitleaks (the AWS example key is allowlisted; the others hit entropy/keyword gates in the keyword-free URL the attack embeds). Reshape the fixtures — three structural, high-entropy shapes gitleaks matches without a keyword (github / slack / gitlab) for the git-push attack, and a separate alphanumeric secret for the DNS attack (a gitleaks-matchable token carries separators that aren't valid DNS labels, so the two uses can't share one secret). All five sandbox-escape tests now pass. Add test_firecracker_launch: a launch smoke (exec + proxy env) gated on `FirecrackerBottleBackend.status() == 0` (the `backend status` result), skipping with setup instructions when the TAP pool / nft table aren't provisioned. The git-gate-only-matches-gitleaks-patterns asymmetry the reshape exposed is tracked separately (#346). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
143 lines
4.8 KiB
Python
143 lines
4.8 KiB
Python
"""Integration: Firecracker microVM launch.
|
|
|
|
End-to-end against a real Firecracker microVM: prepare + launch a bottle
|
|
on the firecracker backend and verify the agent execs after provisioning
|
|
and that the egress proxy env is wired to the sidecar.
|
|
|
|
Gated on the `backend status` result for firecracker (0 == the privileged
|
|
TAP pool + nft isolation table are provisioned). Skips cleanly with setup
|
|
instructions otherwise, so the suite runs on hosts without the pool.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import io
|
|
import os
|
|
import shutil
|
|
import tempfile
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
from bot_bottle.backend import BottleSpec, get_bottle_backend
|
|
from bot_bottle.backend.firecracker import FirecrackerBottleBackend
|
|
from bot_bottle.manifest import ManifestIndex
|
|
|
|
|
|
def _firecracker_status_ok() -> bool:
|
|
"""Gate on `./cli.py backend status --backend=firecracker`: a 0 exit
|
|
means the pool + nft table are ready. Output is captured so the
|
|
decorator stays quiet during collection; any error → not ready."""
|
|
buf = io.StringIO()
|
|
try:
|
|
with contextlib.redirect_stdout(buf), contextlib.redirect_stderr(buf):
|
|
return FirecrackerBottleBackend.status() == 0
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
_SKIP_MSG = (
|
|
"firecracker backend not ready — provision the network pool with "
|
|
"`./cli.py backend setup --backend=firecracker`, then confirm with "
|
|
"`./cli.py backend status --backend=firecracker`"
|
|
)
|
|
|
|
|
|
def _minimal_agent_dockerfile(path: Path) -> None:
|
|
path.write_text(
|
|
"\n".join((
|
|
"FROM node:22-slim",
|
|
"RUN apt-get update \\",
|
|
" && apt-get install -y --no-install-recommends \\",
|
|
" ca-certificates curl git \\",
|
|
" && rm -rf /var/lib/apt/lists/*",
|
|
"USER node",
|
|
"WORKDIR /home/node",
|
|
"CMD [\"sleep\", \"infinity\"]",
|
|
"",
|
|
)),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
|
|
def _minimal_manifest(dockerfile: Path) -> ManifestIndex:
|
|
return ManifestIndex.from_json_obj({
|
|
"bottles": {
|
|
"dev": {
|
|
"agent_provider": {
|
|
"template": "pi",
|
|
"dockerfile": str(dockerfile),
|
|
"settings": {
|
|
"provider": "example",
|
|
"base_url": "https://example.com/v1",
|
|
"models": ["smoke"],
|
|
},
|
|
},
|
|
"egress": {"routes": [{"host": "example.com"}]},
|
|
},
|
|
},
|
|
"agents": {
|
|
"demo": {"skills": [], "prompt": "smoke", "bottle": "dev"},
|
|
},
|
|
})
|
|
|
|
|
|
@unittest.skipIf(
|
|
os.environ.get("GITEA_ACTIONS") == "true",
|
|
"skipped under act_runner: cannot host Firecracker microVMs",
|
|
)
|
|
@unittest.skipUnless(_firecracker_status_ok(), _SKIP_MSG)
|
|
class TestFirecrackerLaunch(unittest.TestCase):
|
|
"""Launch once, reuse the bottle across probes."""
|
|
|
|
@classmethod
|
|
def setUpClass(cls) -> None:
|
|
cls.stage = Path(tempfile.mkdtemp(prefix="cb-firecracker-launch."))
|
|
cls._launch = None
|
|
cls.bottle = None
|
|
dockerfile = cls.stage / "Dockerfile.agent-smoke"
|
|
_minimal_agent_dockerfile(dockerfile)
|
|
os.environ["BOT_BOTTLE_BACKEND"] = "firecracker"
|
|
try:
|
|
backend = get_bottle_backend()
|
|
spec = BottleSpec(
|
|
manifest=_minimal_manifest(dockerfile),
|
|
agent_name="demo",
|
|
copy_cwd=False,
|
|
user_cwd=str(cls.stage),
|
|
)
|
|
cls.plan = backend.prepare(spec, stage_dir=cls.stage)
|
|
cls._launch = backend.launch(cls.plan)
|
|
cls.bottle = cls._launch.__enter__()
|
|
except BaseException:
|
|
if cls._launch is not None:
|
|
cls._launch.__exit__(None, None, None)
|
|
shutil.rmtree(cls.stage, ignore_errors=True)
|
|
os.environ.pop("BOT_BOTTLE_BACKEND", None)
|
|
raise
|
|
|
|
@classmethod
|
|
def tearDownClass(cls) -> None:
|
|
try:
|
|
if cls._launch is not None:
|
|
cls._launch.__exit__(None, None, None)
|
|
finally:
|
|
shutil.rmtree(cls.stage, ignore_errors=True)
|
|
os.environ.pop("BOT_BOTTLE_BACKEND", None)
|
|
|
|
def test_smoke_exec_echo(self) -> None:
|
|
r = self.bottle.exec("echo hello-from-firecracker") # type: ignore[union-attr]
|
|
self.assertEqual(0, r.returncode, msg=r.stderr)
|
|
self.assertIn("hello-from-firecracker", r.stdout)
|
|
|
|
def test_proxy_env_points_at_sidecar(self) -> None:
|
|
r = self.bottle.exec( # type: ignore[union-attr]
|
|
"printf '%s\\n' \"$HTTPS_PROXY\" \"$HTTP_PROXY\""
|
|
)
|
|
self.assertEqual(0, r.returncode, msg=r.stderr)
|
|
self.assertIn("http", r.stdout.lower())
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|