55def3732e
test / stage-firecracker-inputs (pull_request) Successful in 2s
test / integration-docker (pull_request) Successful in 32s
test / unit (pull_request) Successful in 36s
lint / lint (push) Failing after 52s
test / build-infra (pull_request) Successful in 4m5s
tracker-policy-pr / check-pr (pull_request) Successful in 8s
test / integration-firecracker (pull_request) Successful in 2m2s
test / coverage (pull_request) Successful in 2m7s
test / publish-infra (pull_request) Has been skipped
Podman avoids the CAP_SYS_ADMIN requirement that makes rootless Docker impossible here: with no subordinate UID range configured it falls back to a single-UID self-mapping, which an unprivileged process may write itself, so newuidmap is never invoked. The image build therefore strips /etc/subuid and /etc/subgid entries rather than adding them. The agent-facing surface is unchanged — docker and docker compose talk to podman's Docker-compatible API socket. Two device nodes need relaxing as root inside the bottle (0666 on /dev/fuse and /dev/net/tun); both already exist and neither needs a capability the bottle lacks, unlike CAP_SYS_ADMIN. Verified live on macOS 26 / Apple Container 1.0.0: service starts with zero added capabilities, compose pulls and serves from the workspace on a published port, and a nested container cannot reach the network outside the egress path. Known limitation, not yet addressed: the agent base image is Debian bookworm, whose podman 4.3.1 swallows container exit codes through the compat API (docker run returns 0 regardless). podman 5.4.2 on trixie fixes it; the base bump is a separate PR. The acceptance test asserts on an in-band marker rather than an exit code so it cannot false-pass in the meantime. Nested containers give no isolation from the agent itself — root inside one is the agent user outside it. They are a build/test convenience; the bottle remains the security boundary. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GnMp8SUGH57hZX7192Rv2F
155 lines
6.8 KiB
Python
155 lines
6.8 KiB
Python
"""Live-Mac acceptance spike for guest-local rootless podman (issue #392).
|
|
|
|
Run explicitly on an Apple Silicon/macOS 26 host:
|
|
|
|
BOT_BOTTLE_ROOTLESS_PODMAN_SPIKE=1 \
|
|
python3 -m unittest tests.integration.test_macos_rootless_podman_spike -v
|
|
|
|
The opt-in is deliberate: ordinary Linux CI cannot execute Apple Container.
|
|
|
|
Podman rather than Docker because Apple Container's capability bounding set
|
|
omits CAP_SYS_ADMIN; see
|
|
docs/research/rootless-docker-in-apple-container-spike.md. The agent-facing
|
|
surface is still `docker` and `docker compose`, which talk to podman's
|
|
Docker-compatible API socket.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import platform
|
|
import shutil
|
|
import tempfile
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
from bot_bottle.backend import BottleSpec, get_bottle_backend
|
|
from bot_bottle.manifest import ManifestIndex
|
|
|
|
|
|
@unittest.skipUnless(
|
|
platform.system() == "Darwin"
|
|
and os.environ.get("BOT_BOTTLE_ROOTLESS_PODMAN_SPIKE") == "1",
|
|
"requires an explicit live-Mac rootless-podman spike run",
|
|
)
|
|
class TestMacosRootlessPodmanSpike(unittest.TestCase):
|
|
def test_compose_stays_inside_registered_bottle(self) -> None:
|
|
workspace = Path(tempfile.mkdtemp(prefix="rootless-podman-spike."))
|
|
stage = Path(tempfile.mkdtemp(prefix="rootless-podman-stage."))
|
|
try:
|
|
(workspace / "index.html").write_text("bottle-compose-ok\n")
|
|
(workspace / "compose.yaml").write_text(
|
|
"services:\n"
|
|
" web:\n"
|
|
" image: quay.io/prometheus/busybox\n"
|
|
" working_dir: /workspace\n"
|
|
" command: httpd -f -p 8000 -h /workspace\n"
|
|
" volumes: ['.:/workspace']\n"
|
|
" ports: ['18080:8000']\n",
|
|
encoding="utf-8",
|
|
)
|
|
manifest = ManifestIndex.from_json_obj({
|
|
"bottles": {"dev": {
|
|
"docker_access": True,
|
|
# A deliberately tiny image. Pulling a ~165MB one
|
|
# OOM-kills the shared egress proxy, which buffers whole
|
|
# response bodies to scan them — a real defect, but a
|
|
# separate one from what this test covers. See the
|
|
# research note.
|
|
#
|
|
# quay.io deliberately, not Docker Hub: the egress proxy
|
|
# strips agent-set Authorization (so an agent cannot
|
|
# smuggle a credential out in a header), and Docker Hub
|
|
# requires a client-fetched, per-scope bearer token that
|
|
# the strip therefore removes. quay serves manifests with
|
|
# no Authorization at all, so a plain route is enough.
|
|
#
|
|
# token_patterns is still scoped off: registry traffic
|
|
# carries bearer JWTs by protocol and trips the generic
|
|
# rule. known_secrets stays on — it matches the bottle's
|
|
# own credentials, which is the detector that catches
|
|
# real exfil.
|
|
"egress": {"routes": [
|
|
{"host": "quay.io", "dlp": {
|
|
"outbound_detectors": ["known_secrets"],
|
|
}},
|
|
{"host": "cdn01.quay.io", "dlp": {
|
|
"outbound_detectors": ["known_secrets"],
|
|
}},
|
|
]},
|
|
}},
|
|
"agents": {"spike": {
|
|
"bottle": "dev", "skills": [], "prompt": "",
|
|
}},
|
|
})
|
|
spec = BottleSpec(
|
|
manifest=manifest,
|
|
agent_name="spike",
|
|
copy_cwd=True,
|
|
user_cwd=str(workspace),
|
|
)
|
|
backend = get_bottle_backend("macos-container")
|
|
plan = backend.prepare(spec, stage_dir=stage)
|
|
with backend.launch(plan) as bottle:
|
|
workdir = plan.workspace_plan.workdir
|
|
checks = (
|
|
"docker info >/dev/null && docker compose version && "
|
|
f"cd {workdir} && docker compose up -d --wait && "
|
|
"curl --fail --silent http://127.0.0.1:18080/ | "
|
|
"grep -q bottle-compose-ok"
|
|
)
|
|
result = bottle.exec(checks)
|
|
self.assertEqual(
|
|
0, result.returncode,
|
|
f"stdout={result.stdout!r}\nstderr={result.stderr!r}",
|
|
)
|
|
# podman's compat API reports rootlessness through its own
|
|
# native endpoint; the Docker-shaped SecurityOptions field does
|
|
# not carry it.
|
|
inspect = bottle.exec(
|
|
"podman info --format '{{.Host.Security.Rootless}}'"
|
|
)
|
|
self.assertIn("true", inspect.stdout.lower())
|
|
self.assertEqual(
|
|
0,
|
|
bottle.exec(
|
|
"test \"$(id -u)\" -ne 0"
|
|
).returncode,
|
|
"the podman service must not be running as bottle root",
|
|
)
|
|
self.assertNotEqual(
|
|
0,
|
|
bottle.exec("test -S /var/run/docker.sock").returncode,
|
|
"spike must never expose a host/rootful Docker socket",
|
|
)
|
|
# Asserted on an in-band marker, not on `docker run`'s exit
|
|
# code: podman 4.3.1's Docker-compat API swallows the
|
|
# container's status and returns 0 for everything, so an
|
|
# exit-code assertion here passes whether egress was blocked
|
|
# or wide open. That silent false pass is worse than no check
|
|
# at all, and it is exactly this check — the one proving a
|
|
# nested container cannot escape the egress path.
|
|
#
|
|
# busybox ships wget, so a failure here means egress was
|
|
# refused rather than the binary being absent.
|
|
direct = bottle.exec(
|
|
"docker run --rm --env HTTP_PROXY= --env HTTPS_PROXY= "
|
|
"--env http_proxy= --env https_proxy= "
|
|
"quay.io/prometheus/busybox sh -c "
|
|
"'wget -T 4 -qO- https://evil.example.com/ "
|
|
"&& echo ESCAPED || echo CONTAINED'"
|
|
)
|
|
self.assertIn(
|
|
"CONTAINED", direct.stdout,
|
|
"an inner container obtained direct, unproxied egress: "
|
|
f"stdout={direct.stdout!r} stderr={direct.stderr!r}",
|
|
)
|
|
self.assertNotIn("ESCAPED", direct.stdout)
|
|
finally:
|
|
shutil.rmtree(workspace, ignore_errors=True)
|
|
shutil.rmtree(stage, ignore_errors=True)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|