Compare commits
3 Commits
c68d0bb701
...
241df1f835
| Author | SHA1 | Date | |
|---|---|---|---|
| 241df1f835 | |||
| 63a3b9b50a | |||
| 7e6e0b1f5a |
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
[](https://gitea.dideric.is/didericis/bot-bottle/actions?workflow=test.yml)
|
[](https://gitea.dideric.is/didericis/bot-bottle/actions?workflow=test.yml)
|
||||||
[](https://github.com/PyCQA/pylint)
|
[](https://github.com/PyCQA/pylint)
|
||||||
[](https://github.com/microsoft/pyright)
|
[](https://github.com/microsoft/pyright)
|
||||||
|
|
||||||
**Problem:** Developer wants to run a coding agent without supervision, but they don't want a prompt injected or misbehaving agent wrecking their environment or exfiltrating sensitive data.
|
**Problem:** Developer wants to run a coding agent without supervision, but they don't want a prompt injected or misbehaving agent wrecking their environment or exfiltrating sensitive data.
|
||||||
|
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
A bottle is two containers per agent: an `agent` container, and a `sidecars` container that bundles pipelock + cred-proxy + git-gate + supervise behind a Python init supervisor. They share a per-agent Docker `--internal` network; the agent has no default route off-box.
|
A bottle is two containers per agent: an `agent` container, and a `sidecars` container that bundles egress + git-gate + supervise behind a Python init supervisor. They share a per-agent Docker `--internal` network; the agent has no default route off-box.
|
||||||
|
|
||||||
```
|
```
|
||||||
host ( ./cli.py )
|
host ( ./cli.py )
|
||||||
@@ -36,31 +36,25 @@ A bottle is two containers per agent: an `agent` container, and a `sidecars` con
|
|||||||
▼
|
▼
|
||||||
┌─────────────────────────── bottle ──────────────────────────────────┐
|
┌─────────────────────────── bottle ──────────────────────────────────┐
|
||||||
│ │
|
│ │
|
||||||
│ ┌──────────────────┐ ┌──────────────┐ │
|
│ ┌──────────────────┐ ┌──────────────────────┐ │
|
||||||
│ │ agent image │ HTTP(S) proxy │ cred-proxy │ │
|
│ │ agent image │ HTTP(S) proxy │ egress image │ │
|
||||||
│ │ (claude-code, │ ─────────────────►│ (strips/inj │ │
|
│ │ (claude-code, │ ─────────────────►│ (mitmproxy; TLS bump │ │ HTTPS to
|
||||||
│ │ codex, etc) │ │ Authoriz.) │ │
|
│ │ codex, etc) │ │ DLP scan, path │───┼──► allowlisted
|
||||||
│ │ │ └──────┬───────┘ │
|
│ │ │ │ matching, auth │ │ hosts
|
||||||
│ │ environ: URLs │ │ │
|
│ │ environ: proxy │ │ injection) │ │
|
||||||
│ │ only, no real │ ▼ │
|
│ │ URLs only, no │ └──────────────────────┘ │
|
||||||
│ │ tokens │ ┌────────────────┐ │ HTTPS to
|
│ │ real tokens │ │
|
||||||
│ │ │ │ pipelock image │──────────┼──► allowlisted
|
|
||||||
│ │ │ │ (TLS bump, DLP │ │ hosts (incl.
|
|
||||||
│ │ │ │ body scan, │ │ cred-proxy
|
|
||||||
│ │ │ │ allowlist) │ │ upstreams)
|
|
||||||
│ │ │ └────────────────┘ │
|
|
||||||
│ │ │ │
|
|
||||||
│ │ │ git proxy ┌────────────────┐ │ SSH push/fetch
|
│ │ │ git proxy ┌────────────────┐ │ SSH push/fetch
|
||||||
│ │ │ ────────────────►│ git-gate image │──────────┼──► to bottle.git
|
│ │ │ ────────────────►│ git-gate image │──────────┼──► to bottle.git
|
||||||
│ │ │ │ (gitleaks + │ │ upstreams
|
│ │ │ │ (gitleaks + │ │ upstreams
|
||||||
│ └──────────────────┘ │ git daemon) │ │ (direct — not
|
│ └──────────────────┘ │ git daemon) │ │ (direct — not
|
||||||
│ └────────────────┘ │ via pipelock)
|
│ └────────────────┘ │ via egress)
|
||||||
│ │
|
│ │
|
||||||
│ agent on internal network (no default route); pipelock, │
|
│ agent on internal network (no default route); egress and │
|
||||||
│ cred-proxy, and git-gate straddle internal + egress networks. │
|
│ git-gate straddle internal + egress networks. │
|
||||||
│ pipelock is the single HTTP/HTTPS chokepoint — cred-proxy's │
|
│ egress is the single HTTP/HTTPS chokepoint — all agent HTTP/HTTPS │
|
||||||
│ outbound traverses it too. git-gate's SSH egress is direct │
|
│ traffic flows through it. git-gate's SSH egress is direct │
|
||||||
│ because pipelock is HTTP-only. │
|
│ because egress is HTTP-only. │
|
||||||
└─────────────────────────────────────────────────────────────────────┘
|
└─────────────────────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -104,8 +98,6 @@ egress:
|
|||||||
auth:
|
auth:
|
||||||
scheme: token
|
scheme: token
|
||||||
token_ref: BOT_BOTTLE_GITEA_TOKEN
|
token_ref: BOT_BOTTLE_GITEA_TOKEN
|
||||||
pipelock:
|
|
||||||
ssrf_ip_allowlist: [100.78.141.42/32]
|
|
||||||
---
|
---
|
||||||
|
|
||||||
The `gitea-dev` bottle. Provider auth via the inherited Claude route;
|
The `gitea-dev` bottle. Provider auth via the inherited Claude route;
|
||||||
|
|||||||
@@ -14,8 +14,7 @@ import os
|
|||||||
|
|
||||||
# Bundle image. Defaults to a built-locally tag (built from the
|
# Bundle image. Defaults to a built-locally tag (built from the
|
||||||
# repo's Dockerfile.sidecars via compose `build:`). Operators
|
# repo's Dockerfile.sidecars via compose `build:`). Operators
|
||||||
# pinning to a published digest can override via env, matching
|
# pinning to a published digest can override via env.
|
||||||
# the existing `BOT_BOTTLE_PIPELOCK_IMAGE` shape.
|
|
||||||
SIDECAR_BUNDLE_IMAGE = os.environ.get(
|
SIDECAR_BUNDLE_IMAGE = os.environ.get(
|
||||||
"BOT_BOTTLE_SIDECAR_IMAGE",
|
"BOT_BOTTLE_SIDECAR_IMAGE",
|
||||||
"bot-bottle-sidecars:latest",
|
"bot-bottle-sidecars:latest",
|
||||||
|
|||||||
+6
-4
@@ -22,7 +22,9 @@ mounted in. That topology breaks two assumptions those tests make:
|
|||||||
`http://127.0.0.1:<host_port>` from inside the job time out.
|
`http://127.0.0.1:<host_port>` from inside the job time out.
|
||||||
|
|
||||||
The affected tests (`test_orphan_cleanup.test_create_and_remove`,
|
The affected tests (`test_orphan_cleanup.test_create_and_remove`,
|
||||||
`test_pipelock_sidecar_smoke.test_smoke`) still run locally where the
|
`test_sidecar_bundle_image.TestSidecarBundleImage`,
|
||||||
test process and Docker daemon share a host. Making them work in CI
|
`test_sidecar_bundle_compose.TestSidecarBundleCompose`) still run
|
||||||
is a follow-up: either re-write them to discover container IPs via
|
locally where the test process and Docker daemon share a host.
|
||||||
`docker inspect`, or reconfigure the runner with host networking.
|
Making them work in CI is a follow-up: either re-write them to
|
||||||
|
discover container IPs via `docker inspect`, or reconfigure the
|
||||||
|
runner with host networking.
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ egress:
|
|||||||
auth:
|
auth:
|
||||||
scheme: Bearer
|
scheme: Bearer
|
||||||
token_ref: BOT_BOTTLE_CLAUDE_OAUTH_TOKEN
|
token_ref: BOT_BOTTLE_CLAUDE_OAUTH_TOKEN
|
||||||
pipelock:
|
|
||||||
tls_passthrough: true
|
|
||||||
---
|
---
|
||||||
|
|
||||||
Common Claude provider boundary. Drop this file into
|
Common Claude provider boundary. Drop this file into
|
||||||
|
|||||||
+16
-13
@@ -11,16 +11,19 @@ tests/
|
|||||||
fixtures.py # JSON manifest builders (shared)
|
fixtures.py # JSON manifest builders (shared)
|
||||||
_docker.py # docker-availability skip helper (shared)
|
_docker.py # docker-availability skip helper (shared)
|
||||||
unit/
|
unit/
|
||||||
test_pipelock_classify.py
|
test_egress.py
|
||||||
test_pipelock_allowlist.py
|
test_egress_addon_core.py
|
||||||
test_pipelock_yaml.py
|
test_manifest_egress.py
|
||||||
|
test_dlp_detectors.py
|
||||||
test_manifest_runtime.py
|
test_manifest_runtime.py
|
||||||
|
... # many others; see unit/ directory
|
||||||
integration/
|
integration/
|
||||||
test_pipelock_sidecar_smoke.py
|
test_sidecar_bundle_image.py
|
||||||
|
test_sidecar_bundle_compose.py
|
||||||
test_dry_run_plan.py
|
test_dry_run_plan.py
|
||||||
test_orphan_cleanup.py
|
test_orphan_cleanup.py
|
||||||
canaries/
|
...
|
||||||
test_pipelock_image.py # opt-in; see below
|
canaries/ # opt-in; see below (currently empty)
|
||||||
```
|
```
|
||||||
|
|
||||||
Classification falls out of the directory — no hand-maintained list to
|
Classification falls out of the directory — no hand-maintained list to
|
||||||
@@ -32,7 +35,7 @@ keep in sync.
|
|||||||
python -m unittest discover -t . -s tests/unit -v # unit only
|
python -m unittest discover -t . -s tests/unit -v # unit only
|
||||||
python -m unittest discover -t . -s tests/integration -v # integration only
|
python -m unittest discover -t . -s tests/integration -v # integration only
|
||||||
python -m unittest discover -t . -s tests -v # both (recursive)
|
python -m unittest discover -t . -s tests -v # both (recursive)
|
||||||
python -m unittest tests.unit.test_pipelock_yaml # one file
|
python -m unittest tests.unit.test_manifest_egress # one file
|
||||||
```
|
```
|
||||||
|
|
||||||
Discovery is invoked with `-t .` (top-level dir = repo root) so the
|
Discovery is invoked with `-t .` (top-level dir = repo root) so the
|
||||||
@@ -46,18 +49,18 @@ Discovery is invoked with `-t .` (top-level dir = repo root) so the
|
|||||||
- `test_orphan_cleanup.py` — `network_remove` is idempotent against
|
- `test_orphan_cleanup.py` — `network_remove` is idempotent against
|
||||||
missing resources, so the EXIT trap can call it unconditionally.
|
missing resources, so the EXIT trap can call it unconditionally.
|
||||||
- `test_sidecar_bundle_image.py` — builds Dockerfile.sidecars and
|
- `test_sidecar_bundle_image.py` — builds Dockerfile.sidecars and
|
||||||
probes that pipelock / gitleaks / mitmdump / supervise are all
|
probes that gitleaks / mitmdump / supervise are all reachable
|
||||||
reachable inside the bundle.
|
inside the bundle.
|
||||||
- `test_sidecar_bundle_compose.py` — end-to-end compose-up of an
|
- `test_sidecar_bundle_compose.py` — end-to-end compose-up of an
|
||||||
agent + bundle pair; verifies the agent reaches the bundle via
|
agent + bundle pair; verifies the agent reaches the bundle via
|
||||||
the legacy network aliases.
|
the legacy network aliases.
|
||||||
|
|
||||||
## Canaries
|
## Canaries
|
||||||
|
|
||||||
`tests/canaries/` holds upstream-regression checks (e.g. the pinned
|
`tests/canaries/` holds upstream-regression checks gated on
|
||||||
pipelock digest's binary still runs). These are gated on
|
|
||||||
`BOT_BOTTLE_RUN_CANARIES=1` and not part of the per-push suite.
|
`BOT_BOTTLE_RUN_CANARIES=1` and not part of the per-push suite.
|
||||||
They're invoked by the scheduled `canaries` workflow.
|
They're invoked by the scheduled `canaries` workflow. Currently
|
||||||
|
no canaries are defined.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
BOT_BOTTLE_RUN_CANARIES=1 python -m unittest discover -t . -s tests/canaries -v
|
BOT_BOTTLE_RUN_CANARIES=1 python -m unittest discover -t . -s tests/canaries -v
|
||||||
@@ -67,7 +70,7 @@ BOT_BOTTLE_RUN_CANARIES=1 python -m unittest discover -t . -s tests/canaries -v
|
|||||||
|
|
||||||
- `bot_bottle/ssh.py` end-to-end (would need a fake SSH host inside
|
- `bot_bottle/ssh.py` end-to-end (would need a fake SSH host inside
|
||||||
the container).
|
the container).
|
||||||
- A live SSH-through-pipelock tunnel against a real Tailscale-style IP.
|
- A live SSH-through-git-gate tunnel against a real Tailscale-style IP.
|
||||||
- DLP false-positive measurements.
|
- DLP false-positive measurements.
|
||||||
- TLS handling / cert pinning behavior.
|
- TLS handling / cert pinning behavior.
|
||||||
|
|
||||||
|
|||||||
@@ -120,11 +120,10 @@ class TestSandboxEscape(unittest.TestCase):
|
|||||||
# is intentionally unreachable — the pre-receive
|
# is intentionally unreachable — the pre-receive
|
||||||
# gitleaks hook must reject BEFORE git-gate
|
# gitleaks hook must reject BEFORE git-gate
|
||||||
# attempts the upstream push.
|
# attempts the upstream push.
|
||||||
"git": {"remotes": {
|
"git-gate": {"repos": {
|
||||||
"unreachable.invalid": {
|
"throwaway": {
|
||||||
"Name": "throwaway",
|
"url": "ssh://git@unreachable.invalid:22/throwaway.git",
|
||||||
"Upstream": "ssh://git@unreachable.invalid:22/throwaway.git",
|
"identity": str(cls._key_path),
|
||||||
"IdentityFile": str(cls._key_path),
|
|
||||||
},
|
},
|
||||||
}},
|
}},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -110,10 +110,10 @@ class TestSmolmachinesLaunch(unittest.TestCase):
|
|||||||
# (high-numbered) so we're confirming TSI refusal, not
|
# (high-numbered) so we're confirming TSI refusal, not
|
||||||
# just "no service listening."
|
# just "no service listening."
|
||||||
r = self.bottle.exec(
|
r = self.bottle.exec(
|
||||||
"wget -T 3 -t 1 -O - http://127.0.0.1:9 2>&1 || true"
|
"curl -s --show-error --max-time 3 http://127.0.0.1:9 2>&1 || true"
|
||||||
)
|
)
|
||||||
# `wget` to a denied destination produces a connect error.
|
# `curl` to a denied destination produces a connect error.
|
||||||
# The exact phrasing varies (busybox vs gnu); we assert
|
# The exact phrasing varies by curl version; we assert
|
||||||
# the response is NOT the body of any real service.
|
# the response is NOT the body of any real service.
|
||||||
self.assertNotIn("hello-from-vm", r.stdout)
|
self.assertNotIn("hello-from-vm", r.stdout)
|
||||||
self.assertTrue(
|
self.assertTrue(
|
||||||
@@ -126,10 +126,10 @@ class TestSmolmachinesLaunch(unittest.TestCase):
|
|||||||
|
|
||||||
def test_prompt_file_lands_in_guest(self):
|
def test_prompt_file_lands_in_guest(self):
|
||||||
# provision_prompt copies the host-side prompt.txt into the
|
# provision_prompt copies the host-side prompt.txt into the
|
||||||
# guest at /root/.bot-bottle-prompt.txt. The content
|
# guest at /home/node/.bot-bottle-prompt.txt. The content
|
||||||
# must match what the manifest declared so claude-code's
|
# must match what the manifest declared so claude-code's
|
||||||
# --append-system-prompt-file reads the right text.
|
# --append-system-prompt-file reads the right text.
|
||||||
r = self.bottle.exec("cat /root/.bot-bottle-prompt.txt")
|
r = self.bottle.exec("cat /home/node/.bot-bottle-prompt.txt")
|
||||||
self.assertEqual(0, r.returncode, msg=r.stderr)
|
self.assertEqual(0, r.returncode, msg=r.stderr)
|
||||||
self.assertEqual(_AGENT_PROMPT, r.stdout.rstrip("\n"))
|
self.assertEqual(_AGENT_PROMPT, r.stdout.rstrip("\n"))
|
||||||
|
|
||||||
@@ -143,7 +143,7 @@ class TestSmolmachinesLaunch(unittest.TestCase):
|
|||||||
# connect fails, which is the property chunk 3 will
|
# connect fails, which is the property chunk 3 will
|
||||||
# preserve once egress is actually running.
|
# preserve once egress is actually running.
|
||||||
r = self.bottle.exec(
|
r = self.bottle.exec(
|
||||||
f"wget -T 3 -t 1 -O - http://{self.plan.bundle_ip}:9099 "
|
f"curl -s --show-error --max-time 3 http://{self.plan.bundle_ip}:9099 "
|
||||||
"2>&1 || true"
|
"2>&1 || true"
|
||||||
)
|
)
|
||||||
self.assertTrue(
|
self.assertTrue(
|
||||||
|
|||||||
Reference in New Issue
Block a user