Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e74a5e0219 | |||
| b2919b6148 | |||
| 30d3485696 | |||
| 049794f767 | |||
| 921aceb515 | |||
| bea6abc22c | |||
| 87b6259c18 | |||
| 0c8c6b854d | |||
| f4c615f523 | |||
| 09f93542f3 | |||
| b1ecf73fd2 | |||
| 8c4861abde | |||
| d3d74c5b42 | |||
| 954965af46 | |||
| f9895992d9 | |||
| 5592386b1f | |||
| 6aed1bc589 |
@@ -5,43 +5,28 @@
|
||||
# bot-bottle
|
||||
|
||||
[](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)
|
||||
|
||||
**Run any coding agent like it might be compromised — and lose nothing when it is.**
|
||||
**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.
|
||||
|
||||
bot-bottle is a provider-neutral, security-first substrate for autonomous agents. Bring Claude Code, Codex, or your own harness; each one runs in an ephemeral, per-agent "bottle" it cannot modify, where every byte of egress is scanned for exfiltration and capabilities are narrowed to exactly what the task declares.
|
||||
**Solution:** Ephemeral, per agent "bottles" the agent cannot modify that scan all traffic for data exfiltration and limit capabilities and egress to only what the agent needs.
|
||||
|
||||
**Problem:** You want to let a coding agent run unsupervised, but a prompt-injected or misbehaving agent — or a poisoned repo, MCP server, or skill — can wreck your environment or exfiltrate your secrets. Locking yourself to one vendor's cloud doesn't fix that; it just moves the blast radius.
|
||||
## Features
|
||||
|
||||
**Solution:** A neutral control plane that runs *whatever agent you choose* inside an isolation boundary the agent can't touch: TLS-bumped egress allowlisting, outbound/inbound DLP, gitleaks-gated pushes, and host secrets the agent never sees. Swap the agent; keep the guarantees.
|
||||
|
||||
## Why bot-bottle
|
||||
|
||||
### A neutral substrate — bring your own agent
|
||||
|
||||
- **Provider-agnostic by design** — Claude and Codex ship built in; any other agent (Gemini, Aider, a local-model wrapper) is a drop-in plugin at `~/.bot-bottle/contrib/<name>/` — no fork, no PR against this repo. The manifest accepts any provider template, and the isolation, egress, and git guarantees are identical across all of them.
|
||||
- **One control plane, every harness** — the same bottle, egress policy, and supervise flow wrap whichever agent you run, so switching or mixing providers doesn't change your security posture.
|
||||
- **Composable bottles (`extends:`)** — keep provider/runtime policy in one base bottle (e.g. `claude.md`) and overlay task bottles on top.
|
||||
|
||||
### An isolation boundary the agent can't touch
|
||||
|
||||
- **Per-bottle egress allowlist** — TLS-bumped HTTP/HTTPS chokepoint with a per-manifest host allowlist; per-route path/method/header `matches` filtering; outbound DLP scanning for known tokens and secrets, inbound DLP scanning for prompt-injection attempts; DoH and arbitrary hosts blocked by default.
|
||||
- **Per-bottle egress allowlist** — TLS-bumped HTTP/HTTPS chokepoint with a per-manifest host allowlist and request-body DLP scanner; DoH and arbitrary hosts blocked by default.
|
||||
- **Tokens the agent never sees** — host secrets live in a sidecar; the agent dials `http://sidecar:9099/<path>` and the proxy strips inbound `Authorization` and injects the real token before forwarding. `printenv` in the agent shows proxy URLs only.
|
||||
- **Gitleaks-scanned push (git-gate)** — `bottle.git` remotes route through a per-bottle `git daemon` that gitleaks-scans incoming refs pre-receive and forwards clean refs upstream over SSH. The agent never holds the upstream credential.
|
||||
- **Manifest-scoped skills + secrets** — each bottle declares its skills, env, git identity, remotes, and egress routes; unknown keys die at load.
|
||||
- **Trust boundary at `$HOME`** — bottles (credentials, egress, remotes) live only under `~/.bot-bottle/bottles/`. Repos may ship agents but not bottles, so a cloned repo can't redirect an env var to an attacker host.
|
||||
|
||||
### Isolation that matches your host
|
||||
|
||||
- **Composable bottles (`extends:`)** — keep provider/runtime policy in one base bottle (e.g. `claude.md`) and overlay task bottles on top.
|
||||
- **Parallel, isolated bottles** — each bottle runs in its own backend-owned isolation boundary; bottles don't share state or talk to each other.
|
||||
- **Provider templates (Claude, Codex)** — `Dockerfile.claude` / `Dockerfile.codex`, or a bottle-supplied Dockerfile. Claude auth via long-lived OAuth token; Codex via opt-in host device-auth forwarding.
|
||||
- **gVisor auto-detect** — on Linux hosts where `runsc` is registered with Docker, every bottle launches under it for a userspace syscall barrier; no manifest config required.
|
||||
- **Apple Container backend (macOS default when available)** — runs the agent and sidecar bundle with Apple's `container` CLI, using a host-only agent network plus a separate sidecar egress network.
|
||||
- **Smolmachines backend** — runs the agent in a libkrun micro-VM while the sidecar bundle stays in Docker. TSI and smolmachines DNS filtering close the raw DNS exfiltration gap that exists in the legacy Docker backend.
|
||||
- **Legacy Docker backend** — still available for examples, CI, and hosts without Apple Container via `BOT_BOTTLE_BACKEND=docker` or `--backend=docker`.
|
||||
|
||||
Per-provider auth (Claude long-lived OAuth token; Codex opt-in host device-auth forwarding) and per-provider images (`Dockerfile.claude` / `Dockerfile.codex`, or a bottle-supplied Dockerfile) are configured on the bottle — see [Manifest](#manifest).
|
||||
|
||||
## Architecture
|
||||
|
||||
On the default macOS Apple Container backend, a bottle is an agent container on a host-only internal network plus a sidecar bundle attached to both that internal network and a NAT egress network. The agent gets HTTP(S)_PROXY and CA bundle env vars pointing at the sidecar's internal-network IP, so HTTP/HTTPS traffic flows through the sidecar instead of direct egress. `bottle.git` / git-gate is intentionally deferred on this backend until a safe Apple Container key-delivery path exists.
|
||||
@@ -83,27 +68,6 @@ The Docker topology looks like this:
|
||||
|
||||
When the agent exits, `cli.py` tears down every sidecar and both networks; nothing about a bottle persists between runs.
|
||||
|
||||
## Install
|
||||
|
||||
Install the CLI with the bootstrap script:
|
||||
|
||||
```sh
|
||||
curl -fsSL https://gitea.dideric.is/didericis/bot-bottle/raw/branch/main/install.sh | sh
|
||||
```
|
||||
|
||||
The script checks Python 3.11+, checks Docker daemon reachability, creates the `~/.bot-bottle/` config directories, installs the Python package with `pipx` when available or `pip --user` otherwise, then runs:
|
||||
|
||||
```sh
|
||||
bot-bottle doctor
|
||||
```
|
||||
|
||||
Python-native installers can use the package metadata directly:
|
||||
|
||||
```sh
|
||||
pipx install git+https://gitea.dideric.is/didericis/bot-bottle.git
|
||||
uv tool install git+https://gitea.dideric.is/didericis/bot-bottle.git
|
||||
```
|
||||
|
||||
## Quickstart
|
||||
|
||||
On compatible macOS hosts, the default backend requires Apple's `container` CLI and does not require Docker. The smolmachines backend requires Docker on the host for the sidecar bundle plus smolvm. The legacy Docker backend requires Docker. Claude bottles also need a long-lived Claude Code OAuth token (`claude setup-token`) exported as `BOT_BOTTLE_CLAUDE_OAUTH_TOKEN`.
|
||||
@@ -142,15 +106,8 @@ egress:
|
||||
routes:
|
||||
- host: gitea.dideric.is
|
||||
auth:
|
||||
scheme: token # Bearer | token
|
||||
scheme: token
|
||||
token_ref: BOT_BOTTLE_GITEA_TOKEN
|
||||
matches: # optional — restrict to specific paths/methods/headers
|
||||
- paths:
|
||||
- {type: prefix, value: /api/v1/}
|
||||
methods: [GET, POST, PATCH, DELETE]
|
||||
dlp: # optional — per-route detector overrides (default: all on)
|
||||
outbound_detectors: [token_patterns, known_secrets]
|
||||
inbound_detectors: false # disable response scanning for this host
|
||||
---
|
||||
|
||||
The `gitea-dev` bottle. Provider auth via the inherited Claude route;
|
||||
@@ -169,23 +126,6 @@ skills:
|
||||
You help maintain Gitea-hosted projects.
|
||||
````
|
||||
|
||||
**Egress route fields:**
|
||||
|
||||
| Field | Required | Description |
|
||||
|---|---|---|
|
||||
| `host` | yes | Hostname to allowlist. One entry per host. |
|
||||
| `role` | no | Reserved for future use. The key is recognised but any value is currently rejected at load. Provider auth routes (e.g. Claude's `api.anthropic.com`) are injected automatically from `agent_provider.auth_token`, not via `role`. |
|
||||
| `auth.scheme` | when `auth` present | `Bearer` or `token`. Injected by the proxy; the agent never sees the value. |
|
||||
| `auth.token_ref` | when `auth` present | Env-var name holding the secret on the host. |
|
||||
| `matches` | no | Array of `{paths, methods, headers}` filters. A request must match at least one entry (if any are given) to be forwarded. |
|
||||
| `matches[].paths` | no | Array of `{type, value}`. `type` is `prefix` (default), `exact`, or `regex`. |
|
||||
| `matches[].methods` | no | Array of HTTP method strings, e.g. `[GET, POST]`. |
|
||||
| `matches[].headers` | no | Array of `{name, value, type}`. `type` is `exact` (default) or `regex`. |
|
||||
| `dlp` | no | Per-route DLP overrides. Omit to use defaults (all detectors on). |
|
||||
| `dlp.outbound_detectors` | no | `false` disables outbound scanning; list restricts to named detectors (`token_patterns`, `known_secrets`). |
|
||||
| `dlp.inbound_detectors` | no | `false` disables inbound scanning; list restricts to named detectors (`naive_injection_detection`). |
|
||||
| `git.fetch` | no | `true` permits smart HTTP clone/fetch (`git-upload-pack`) for this host. Push (`git-receive-pack`) remains blocked. |
|
||||
|
||||
More examples in `examples/`. Full design lives under `docs/prds/`; the trust-boundary rationale is in `docs/prds/0011-per-file-md-manifest.md`.
|
||||
|
||||
## Trademarks
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
# Per-bottle sidecar bundle image (PRD 0024).
|
||||
#
|
||||
# Collapses the prior per-sidecar images (egress, git-gate,
|
||||
# supervise) into one. A small stdlib-Python init supervisor at
|
||||
# /app/sidecar_init.py spawns all daemons, forwards SIGTERM, and
|
||||
# propagates per-daemon stdout/stderr to the container log with a
|
||||
# `[name]` prefix. See PRD 0024 for the rationale.
|
||||
#
|
||||
# Layout:
|
||||
#
|
||||
# /usr/bin/gitleaks gitleaks binary
|
||||
# /app/egress_addon.py + siblings mitmproxy addon (egress)
|
||||
# /app/egress-entrypoint.sh mitmdump launcher
|
||||
# /app/supervise_server.py + .py supervise MCP server
|
||||
# /app/sidecar_init.py PID 1 supervisor
|
||||
# /etc/egress/routes.yaml bind-mounted at run time
|
||||
# /etc/git-gate/pre-receive docker-cp'd at start time
|
||||
# /git-gate-entrypoint.sh docker-cp'd at start time
|
||||
# /git-gate/creds/* docker-cp'd at start time
|
||||
# /git/* bare repos, populated at runtime
|
||||
# /run/supervise/queue/ bind-mounted at run time
|
||||
# /home/mitmproxy/.mitmproxy/ mitmproxy CA dir
|
||||
#
|
||||
# Exposed ports inside the container:
|
||||
# 9099 egress (mitmproxy, agent-facing HTTPS proxy)
|
||||
# 9418 git-gate (git-daemon)
|
||||
# 9420 git-gate smart HTTP (smolmachines agent-facing transport)
|
||||
# 9100 supervise (MCP HTTP)
|
||||
|
||||
# Stage 1: gitleaks binary. The upstream gitleaks image is alpine
|
||||
# with the binary at /usr/bin/gitleaks. Pinned by digest in lockstep
|
||||
# with Dockerfile.git-gate's prior base (now deleted at chunk 3).
|
||||
FROM zricethezav/gitleaks@sha256:c00b6bd0aeb3071cbcb79009cb16a60dd9e0a7c60e2be9ab65d25e6bc8abbb7f AS gitleaks-src
|
||||
|
||||
# Stage 2: assembly. mitmproxy/mitmproxy is debian-slim-based with
|
||||
# Python + mitmdump pre-installed — heavier than the others, so
|
||||
# this stage starts there and pulls the standalone binaries in.
|
||||
FROM mitmproxy/mitmproxy:11.1.3
|
||||
|
||||
# Run as root inside the bundle. The bundle is the isolation
|
||||
# boundary; per-daemon user separation inside it is not load-bearing
|
||||
# and complicates the supervisor's spawn path.
|
||||
USER root
|
||||
|
||||
# Runtime system deps:
|
||||
# git supplies the `git daemon` subcommand (no separate package)
|
||||
# plus the core `git` binary the pre-receive hook invokes.
|
||||
# openssh-client supplies the upstream SSH transport the
|
||||
# pre-receive hook uses to forward accepted refs.
|
||||
# ca-certificates is needed for mitmdump upstream TLS (the
|
||||
# base image already has it; listed for explicitness).
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
git openssh-client ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Pull the standalone binaries into the final image.
|
||||
COPY --from=gitleaks-src /usr/bin/gitleaks /usr/bin/gitleaks
|
||||
|
||||
# Project Python: addon + server modules + the init supervisor.
|
||||
# Kept flat under /app/ so mitmdump's loader resolves them as
|
||||
# top-level siblings (absolute imports), matching the prior
|
||||
# Dockerfile.egress / Dockerfile.supervise layout.
|
||||
COPY bot_bottle/egress_addon_core.py /app/egress_addon_core.py
|
||||
COPY bot_bottle/egress_addon.py /app/egress_addon.py
|
||||
COPY bot_bottle/dlp_detectors.py /app/dlp_detectors.py
|
||||
COPY bot_bottle/yaml_subset.py /app/yaml_subset.py
|
||||
COPY bot_bottle/supervise.py /app/supervise.py
|
||||
COPY bot_bottle/supervise_server.py /app/supervise_server.py
|
||||
COPY bot_bottle/sidecar_init.py /app/sidecar_init.py
|
||||
COPY bot_bottle/git_http_backend.py /app/git_http_backend.py
|
||||
COPY bot_bottle/egress_entrypoint.sh /app/egress-entrypoint.sh
|
||||
RUN chmod +x /app/egress-entrypoint.sh
|
||||
|
||||
# Pre-create runtime directories the compose renderer + start
|
||||
# step expect to exist. `docker cp` does not create intermediate
|
||||
# dirs, and bind mounts won't either if the parent is missing.
|
||||
RUN mkdir -p \
|
||||
/etc/egress \
|
||||
/etc/git-gate \
|
||||
/git-gate/creds \
|
||||
/git \
|
||||
/run/supervise/queue \
|
||||
/home/mitmproxy/.mitmproxy
|
||||
|
||||
# Documentation only — the compose renderer publishes whichever
|
||||
# subset the bottle uses.
|
||||
EXPOSE 8888 9099 9418 9420 9100
|
||||
|
||||
# WORKDIR matches Dockerfile.supervise's prior layout so the
|
||||
# in-app same-dir import in supervise_server.py stays deterministic.
|
||||
WORKDIR /app
|
||||
|
||||
# PID 1 is the supervisor. It owns signal handling and exit-code
|
||||
# propagation; no `exec` chain in the entrypoint itself.
|
||||
ENTRYPOINT ["python3", "/app/sidecar_init.py"]
|
||||
@@ -58,17 +58,10 @@ from .sidecar_bundle import (
|
||||
)
|
||||
|
||||
|
||||
# Repo root or installed site-packages root, used as the build context for
|
||||
# Dockerfiles that COPY bot_bottle source files.
|
||||
# Repo root, used as the build context for the bundle Dockerfile.
|
||||
_REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent)
|
||||
|
||||
|
||||
def _sidecar_bundle_dockerfile() -> str:
|
||||
if (Path(_REPO_DIR) / SIDECAR_BUNDLE_DOCKERFILE).is_file():
|
||||
return SIDECAR_BUNDLE_DOCKERFILE
|
||||
return f"bot_bottle/{SIDECAR_BUNDLE_DOCKERFILE}"
|
||||
|
||||
|
||||
def bottle_plan_to_compose(plan: DockerBottlePlan) -> dict[str, Any]:
|
||||
"""Render a Compose v2 spec dict from a fully-resolved
|
||||
DockerBottlePlan.
|
||||
@@ -190,7 +183,7 @@ def _sidecar_bundle_service(plan: DockerBottlePlan) -> dict[str, Any]:
|
||||
"image": SIDECAR_BUNDLE_IMAGE,
|
||||
"build": {
|
||||
"context": _REPO_DIR,
|
||||
"dockerfile": _sidecar_bundle_dockerfile(),
|
||||
"dockerfile": SIDECAR_BUNDLE_DOCKERFILE,
|
||||
},
|
||||
"container_name": sidecar_bundle_container_name(plan.slug),
|
||||
"networks": {
|
||||
|
||||
@@ -12,10 +12,9 @@ from __future__ import annotations
|
||||
import os
|
||||
|
||||
|
||||
# Bundle image. Defaults to a built-locally tag. Source checkouts
|
||||
# build from the repo-root Dockerfile.sidecars; installed packages
|
||||
# build from the packaged copy under bot_bottle/.
|
||||
# Operators pinning to a published digest can override via env.
|
||||
# Bundle image. Defaults to a built-locally tag (built from the
|
||||
# repo's Dockerfile.sidecars via compose `build:`). Operators
|
||||
# pinning to a published digest can override via env.
|
||||
SIDECAR_BUNDLE_IMAGE = os.environ.get(
|
||||
"BOT_BOTTLE_SIDECAR_IMAGE",
|
||||
"bot-bottle-sidecars:latest",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Main CLI dispatcher.
|
||||
|
||||
Commands: cleanup, commit, doctor, edit, info, init, list, resume, start, supervise
|
||||
Commands: cleanup, commit, edit, info, init, list, resume, start, supervise
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -13,7 +13,6 @@ from ._common import PROG
|
||||
from . import list as _list_mod
|
||||
from .cleanup import cmd_cleanup
|
||||
from .commit import cmd_commit
|
||||
from .doctor import cmd_doctor
|
||||
from .edit import cmd_edit
|
||||
from .info import cmd_info
|
||||
from .init import cmd_init
|
||||
@@ -26,7 +25,6 @@ cmd_list = _list_mod.cmd_list
|
||||
COMMANDS = {
|
||||
"cleanup": cmd_cleanup,
|
||||
"commit": cmd_commit,
|
||||
"doctor": cmd_doctor,
|
||||
"edit": cmd_edit,
|
||||
"info": cmd_info,
|
||||
"init": cmd_init,
|
||||
@@ -42,7 +40,6 @@ def usage() -> None:
|
||||
sys.stderr.write("Commands:\n")
|
||||
sys.stderr.write(" cleanup stop and remove all active bot-bottle containers\n")
|
||||
sys.stderr.write(" commit snapshot a running bottle's container state to a Docker image\n")
|
||||
sys.stderr.write(" doctor check Python, Docker, and bot-bottle config prerequisites\n")
|
||||
sys.stderr.write(" edit open an agent in vim for editing\n")
|
||||
sys.stderr.write(" info print env, skills, and prompt details for a named agent\n")
|
||||
sys.stderr.write(" init interactively create a new agent and add it to bot-bottle.json\n")
|
||||
|
||||
@@ -6,7 +6,7 @@ import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
PROG = Path(sys.argv[0]).name or "bot-bottle"
|
||||
PROG = "cli.py"
|
||||
USER_CWD = os.getcwd()
|
||||
REPO_DIR = str(Path(__file__).resolve().parent.parent.parent)
|
||||
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
"""doctor: validate host prerequisites for running bot-bottle."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from ._common import PROG
|
||||
|
||||
|
||||
def _ok(label: str, detail: str) -> None:
|
||||
print(f"ok: {label}: {detail}")
|
||||
|
||||
|
||||
def _fail(label: str, detail: str) -> None:
|
||||
print(f"fail: {label}: {detail}")
|
||||
|
||||
|
||||
def _check_python() -> bool:
|
||||
version = sys.version_info
|
||||
detail = f"{version.major}.{version.minor}.{version.micro}"
|
||||
if version >= (3, 11):
|
||||
_ok("python", detail)
|
||||
return True
|
||||
_fail("python", f"{detail}; need 3.11 or newer")
|
||||
return False
|
||||
|
||||
|
||||
def _check_docker() -> bool:
|
||||
docker = shutil.which("docker")
|
||||
if not docker:
|
||||
_fail("docker", "docker command not found")
|
||||
return False
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[docker, "info"],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=False,
|
||||
timeout=10,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||
_fail("docker", f"daemon check failed: {exc}")
|
||||
return False
|
||||
if result.returncode == 0:
|
||||
_ok("docker", "daemon reachable")
|
||||
return True
|
||||
_fail("docker", "daemon not reachable")
|
||||
return False
|
||||
|
||||
|
||||
def _check_config_dir() -> bool:
|
||||
config = Path.home() / ".bot-bottle"
|
||||
if config.is_dir():
|
||||
_ok("config", str(config))
|
||||
return True
|
||||
_fail("config", f"{config} does not exist")
|
||||
return False
|
||||
|
||||
|
||||
def cmd_doctor(argv: list[str]) -> int:
|
||||
parser = argparse.ArgumentParser(prog=f"{PROG} doctor", add_help=True)
|
||||
parser.parse_args(argv)
|
||||
|
||||
checks = (
|
||||
_check_python(),
|
||||
_check_docker(),
|
||||
_check_config_dir(),
|
||||
)
|
||||
return 0 if all(checks) else 1
|
||||
@@ -53,7 +53,6 @@ from ..supervise import (
|
||||
TOOL_CAPABILITY_BLOCK,
|
||||
TOOL_ALLOW,
|
||||
TOOL_EGRESS_BLOCK,
|
||||
TOOL_GITLEAKS_ALLOW,
|
||||
archive_proposal,
|
||||
list_pending_proposals,
|
||||
render_diff,
|
||||
@@ -141,8 +140,6 @@ def _suffix_for_tool(tool: str) -> str:
|
||||
return ".dockerfile"
|
||||
if tool in (TOOL_ALLOW, TOOL_EGRESS_BLOCK):
|
||||
return ".yaml"
|
||||
if tool == TOOL_GITLEAKS_ALLOW:
|
||||
return ".txt"
|
||||
return ".txt"
|
||||
|
||||
|
||||
@@ -204,23 +201,6 @@ def reject(qp: QueuedProposal, *, reason: str) -> None:
|
||||
_write_audit(qp, action=STATUS_REJECTED, notes=reason, diff_before="", diff_after="")
|
||||
|
||||
|
||||
def _approve_from_tui(
|
||||
stdscr: "curses._CursesWindow", # type: ignore
|
||||
qp: QueuedProposal,
|
||||
*,
|
||||
final_file: str | None = None,
|
||||
notes: str = "",
|
||||
) -> str:
|
||||
"""Approve from curses, prompting for any tool-specific audit note."""
|
||||
if qp.proposal.tool == TOOL_GITLEAKS_ALLOW and final_file is None:
|
||||
notes = _prompt(stdscr, "allow reason (test fixture/false positive): ")
|
||||
if not notes:
|
||||
return "approve aborted (empty reason)"
|
||||
approve(qp, final_file=final_file, notes=notes)
|
||||
verb = "modified+approved" if final_file is not None else "approved"
|
||||
return _approval_status(qp, verb)
|
||||
|
||||
|
||||
def _write_audit(
|
||||
qp: QueuedProposal,
|
||||
*,
|
||||
@@ -404,22 +384,18 @@ def _main_loop(stdscr: "curses._CursesWindow") -> None: # type: ignore
|
||||
_detail_view(stdscr, qp, green_attr=green_attr)
|
||||
elif key == ord("a"):
|
||||
try:
|
||||
status_line = _approve_from_tui(stdscr, qp)
|
||||
approve(qp)
|
||||
status_line = _approval_status(qp, "approved")
|
||||
except ApplyError as e:
|
||||
status_line = f"apply failed: {e}"
|
||||
elif key == ord("m"):
|
||||
if qp.proposal.tool == TOOL_GITLEAKS_ALLOW:
|
||||
status_line = "modify unavailable for gitleaks-allow"
|
||||
continue
|
||||
edited = _modify(stdscr, qp)
|
||||
if edited is None:
|
||||
status_line = "modify aborted (no change)"
|
||||
else:
|
||||
try:
|
||||
status_line = _approve_from_tui(
|
||||
stdscr, qp, final_file=edited,
|
||||
notes="operator modified before approving",
|
||||
)
|
||||
approve(qp, final_file=edited, notes="operator modified before approving")
|
||||
status_line = _approval_status(qp, "modified+approved")
|
||||
except ApplyError as e:
|
||||
status_line = f"apply failed: {e}"
|
||||
elif key == ord("r"):
|
||||
@@ -517,20 +493,15 @@ def _detail_view(
|
||||
offset = max(0, len(lines) - 1)
|
||||
elif key == ord("a"):
|
||||
try:
|
||||
_approve_from_tui(stdscr, qp)
|
||||
approve(qp)
|
||||
except ApplyError:
|
||||
pass
|
||||
return
|
||||
elif key == ord("m"):
|
||||
if qp.proposal.tool == TOOL_GITLEAKS_ALLOW:
|
||||
return
|
||||
edited = _modify(stdscr, qp)
|
||||
if edited is not None:
|
||||
try:
|
||||
_approve_from_tui(
|
||||
stdscr, qp, final_file=edited,
|
||||
notes="operator modified before approving",
|
||||
)
|
||||
approve(qp, final_file=edited, notes="operator modified before approving")
|
||||
except ApplyError:
|
||||
pass
|
||||
return
|
||||
|
||||
@@ -247,164 +247,6 @@ cat > "$refs_file"
|
||||
|
||||
zero=0000000000000000000000000000000000000000
|
||||
|
||||
supervise_gitleaks_allow() {
|
||||
log_opts=$1
|
||||
ref=$2
|
||||
report_file=$(mktemp)
|
||||
if ! gitleaks git \
|
||||
--log-opts="$log_opts" \
|
||||
--no-banner \
|
||||
--redact \
|
||||
--ignore-gitleaks-allow \
|
||||
--report-format=json \
|
||||
--report-path="$report_file" \
|
||||
--exit-code 0 \
|
||||
1>&2; then
|
||||
rm -f "$report_file"
|
||||
echo "git-gate: gitleaks inline-suppression scan failed for $ref" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
proposal_id=$(
|
||||
GITLEAKS_ALLOW_REF="$ref" python3 - "$report_file" <<'PY'
|
||||
import datetime
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
report_path = Path(sys.argv[1])
|
||||
queue_dir = os.environ.get("SUPERVISE_QUEUE_DIR", "")
|
||||
slug = os.environ.get("SUPERVISE_BOTTLE_SLUG", "")
|
||||
if not queue_dir or not slug:
|
||||
sys.exit(2)
|
||||
|
||||
try:
|
||||
raw = json.loads(report_path.read_text() or "[]")
|
||||
except json.JSONDecodeError:
|
||||
sys.exit(3)
|
||||
if not isinstance(raw, list):
|
||||
sys.exit(3)
|
||||
if not raw:
|
||||
sys.exit(0)
|
||||
|
||||
ref = os.environ.get("GITLEAKS_ALLOW_REF", "")
|
||||
lines = [
|
||||
"gitleaks inline suppression requires supervisor approval",
|
||||
f"ref: {ref}",
|
||||
"",
|
||||
]
|
||||
for i, finding in enumerate(raw, 1):
|
||||
if not isinstance(finding, dict):
|
||||
continue
|
||||
file_path = finding.get("File", "")
|
||||
line_no = finding.get("StartLine", finding.get("Line", ""))
|
||||
rule_id = finding.get("RuleID", "")
|
||||
commit = finding.get("Commit", "")
|
||||
line = finding.get("Line", "")
|
||||
lines.extend([
|
||||
f"finding {i}:",
|
||||
f" file: {file_path}",
|
||||
f" line: {line_no}",
|
||||
f" rule: {rule_id}",
|
||||
f" commit: {commit}",
|
||||
f" code: {line}",
|
||||
"",
|
||||
])
|
||||
|
||||
payload = "\n".join(lines).rstrip() + "\n"
|
||||
proposal_id = str(uuid.uuid4())
|
||||
proposal = {
|
||||
"id": proposal_id,
|
||||
"bottle_slug": slug,
|
||||
"tool": "gitleaks-allow",
|
||||
"proposed_file": payload,
|
||||
"justification": (
|
||||
"git-gate found gitleaks findings hidden by # gitleaks:allow; "
|
||||
"approve only for dummy test fixtures or confirmed false positives"
|
||||
),
|
||||
"arrival_timestamp": datetime.datetime.now(
|
||||
datetime.timezone.utc
|
||||
).isoformat(),
|
||||
"current_file_hash": hashlib.sha256(payload.encode("utf-8")).hexdigest(),
|
||||
}
|
||||
queue = Path(queue_dir)
|
||||
queue.mkdir(parents=True, exist_ok=True)
|
||||
path = queue / f"{proposal_id}.proposal.json"
|
||||
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||
with tmp.open("w", encoding="utf-8") as f:
|
||||
json.dump(proposal, f, indent=2)
|
||||
f.write("\n")
|
||||
os.chmod(tmp, 0o600)
|
||||
os.replace(tmp, path)
|
||||
print(proposal_id)
|
||||
PY
|
||||
)
|
||||
rc=$?
|
||||
rm -f "$report_file"
|
||||
if [ "$rc" -eq 0 ] && [ -z "$proposal_id" ]; then
|
||||
return 0
|
||||
fi
|
||||
if [ "$rc" -ne 0 ]; then
|
||||
echo "git-gate: cannot route # gitleaks:allow finding to supervisor; refusing push" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
queue_dir=${SUPERVISE_QUEUE_DIR:-}
|
||||
response_file="$queue_dir/${proposal_id}.response.json"
|
||||
timeout=${SUPERVISE_GITLEAKS_ALLOW_TIMEOUT_SECONDS:-300}
|
||||
case "$timeout" in
|
||||
''|*[!0-9]*)
|
||||
echo "git-gate: invalid SUPERVISE_GITLEAKS_ALLOW_TIMEOUT_SECONDS=$timeout" >&2
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
echo "git-gate: queued # gitleaks:allow supervisor approval $proposal_id" >&2
|
||||
echo "git-gate: approve with './cli.py supervise' to continue this push" >&2
|
||||
waited=0
|
||||
while [ "$waited" -lt "$timeout" ]; do
|
||||
if [ -f "$response_file" ]; then
|
||||
status=$(python3 - "$response_file" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
try:
|
||||
with open(sys.argv[1], encoding="utf-8") as f:
|
||||
raw = json.load(f)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
sys.exit(1)
|
||||
status = raw.get("status")
|
||||
if not isinstance(status, str):
|
||||
sys.exit(1)
|
||||
print(status)
|
||||
PY
|
||||
) || status=""
|
||||
case "$status" in
|
||||
approved|modified)
|
||||
mkdir -p "$queue_dir/processed"
|
||||
mv -f "$queue_dir/${proposal_id}.proposal.json" "$queue_dir/processed/" 2>/dev/null || true
|
||||
mv -f "$queue_dir/${proposal_id}.response.json" "$queue_dir/processed/" 2>/dev/null || true
|
||||
echo "git-gate: supervisor approved # gitleaks:allow for $ref" >&2
|
||||
return 0
|
||||
;;
|
||||
rejected)
|
||||
echo "git-gate: supervisor rejected # gitleaks:allow for $ref" >&2
|
||||
return 1
|
||||
;;
|
||||
*)
|
||||
echo "git-gate: invalid supervisor response for # gitleaks:allow" >&2
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
sleep 1
|
||||
waited=$((waited + 1))
|
||||
done
|
||||
echo "git-gate: supervisor approval timed out for # gitleaks:allow; refusing push" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
# Phase 1: gitleaks scan each ref's incoming commits.
|
||||
while IFS=' ' read -r old new ref; do
|
||||
[ -z "$ref" ] && continue
|
||||
@@ -426,9 +268,6 @@ while IFS=' ' read -r old new ref; do
|
||||
echo "git-gate: gitleaks rejected push to $ref" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! supervise_gitleaks_allow "$log_opts" "$ref"; then
|
||||
exit 1
|
||||
fi
|
||||
done < "$refs_file"
|
||||
|
||||
# Phase 2: forward each ref to the upstream (`origin`, configured
|
||||
|
||||
@@ -19,7 +19,7 @@ Bottle schema (frontmatter):
|
||||
repos: { <name>: <git-gate-entry>, ... } # optional
|
||||
egress: { routes: [ <egress-route>, ... ] }
|
||||
# route keys: host, matches, auth, role, dlp
|
||||
supervise: <bool> # optional (default true)
|
||||
supervise: <bool> # optional
|
||||
|
||||
Agent schema (frontmatter):
|
||||
bottle: <bottle-name> # required
|
||||
@@ -111,13 +111,13 @@ class ManifestBottle:
|
||||
# identity without any git-gate.repos upstreams, and vice versa.
|
||||
git_user: ManifestGitUser = field(default_factory=ManifestGitUser)
|
||||
egress: ManifestEgressConfig = field(default_factory=ManifestEgressConfig)
|
||||
# Per-bottle stuck-recovery sidecar (PRD 0013). When true (the
|
||||
# default, issue #249), the launch step brings up a supervise
|
||||
# sidecar that exposes MCP tools to the agent (egress-block,
|
||||
# capability-block) plus mounts the current-config dir read-only
|
||||
# into the agent at /etc/bot-bottle/current-config. Set
|
||||
# `supervise: false` to skip the sidecar and mount.
|
||||
supervise: bool = True
|
||||
# Opt-in per-bottle stuck-recovery sidecar (PRD 0013). When true,
|
||||
# the launch step brings up a supervise sidecar that exposes MCP
|
||||
# tools to the agent (egress-block, capability-block) plus mounts
|
||||
# the current-config dir read-only into the agent at
|
||||
# /etc/bot-bottle/current-config. False (the default) skips the
|
||||
# sidecar and mount.
|
||||
supervise: bool = False
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, name: str, raw: object) -> "ManifestBottle":
|
||||
@@ -190,7 +190,7 @@ class ManifestBottle:
|
||||
else ManifestEgressConfig()
|
||||
)
|
||||
|
||||
supervise_raw = d.get("supervise", True)
|
||||
supervise_raw = d.get("supervise", False)
|
||||
if not isinstance(supervise_raw, bool):
|
||||
raise ManifestError(
|
||||
f"bottle '{name}' supervise must be a boolean "
|
||||
|
||||
@@ -51,13 +51,11 @@ SUPERVISE_PORT = 9100
|
||||
TOOL_CAPABILITY_BLOCK = "capability-block"
|
||||
TOOL_EGRESS_BLOCK = "egress-block"
|
||||
TOOL_ALLOW = "allow"
|
||||
TOOL_GITLEAKS_ALLOW = "gitleaks-allow"
|
||||
TOOL_LIST_EGRESS_ROUTES = "list-egress-routes"
|
||||
TOOLS: tuple[str, ...] = (
|
||||
TOOL_ALLOW,
|
||||
TOOL_CAPABILITY_BLOCK,
|
||||
TOOL_EGRESS_BLOCK,
|
||||
TOOL_GITLEAKS_ALLOW,
|
||||
TOOL_LIST_EGRESS_ROUTES,
|
||||
)
|
||||
|
||||
@@ -555,7 +553,6 @@ __all__ = [
|
||||
"EGRESS_FORWARD_PROXY",
|
||||
"EGRESS_INTROSPECT_URL",
|
||||
"TOOL_CAPABILITY_BLOCK",
|
||||
"TOOL_GITLEAKS_ALLOW",
|
||||
"TOOL_LIST_EGRESS_ROUTES",
|
||||
"archive_proposal",
|
||||
"audit_dir",
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
# PRD 0061: Gitleaks inline suppression supervision
|
||||
|
||||
- **Status:** Active
|
||||
- **Author:** codex
|
||||
- **Created:** 2026-06-10
|
||||
- **Issue:** #208
|
||||
|
||||
## Summary
|
||||
|
||||
Route every `# gitleaks:allow` inline suppression through the existing
|
||||
supervisor approval queue before git-gate forwards a push upstream. The
|
||||
annotation remains available for test fixtures and confirmed false positives,
|
||||
but it no longer lets an agent silently bypass the gate's secret scan.
|
||||
|
||||
## Problem
|
||||
|
||||
Gitleaks supports inline suppression comments. With the default behaviour,
|
||||
a pushed line that contains a secret-like value and `# gitleaks:allow` is
|
||||
ignored by gitleaks and accepted by git-gate. That is convenient for dummy
|
||||
credentials in tests, but it also gives an agent a one-line bypass for a real
|
||||
secret. Once the commit lands, the diff alone cannot prove whether the
|
||||
suppression was legitimate.
|
||||
|
||||
## Goals / Success Criteria
|
||||
|
||||
1. Git-gate continues to run the normal gitleaks scan for every incoming ref.
|
||||
2. After the normal scan passes, git-gate runs a second scan with
|
||||
`--ignore-gitleaks-allow` and a JSON report so suppressed findings become
|
||||
visible.
|
||||
3. If that second scan reports no suppressed findings, the push proceeds
|
||||
unchanged.
|
||||
4. If it reports suppressed findings, git-gate creates a `gitleaks-allow`
|
||||
supervisor proposal containing the ref, file path, line number, rule,
|
||||
commit, and flagged line for each finding.
|
||||
5. The push proceeds only when the supervisor explicitly approves the
|
||||
proposal; rejection, malformed responses, missing supervisor configuration,
|
||||
and timeout all refuse the push.
|
||||
6. The supervisor TUI requires a reason when approving a `gitleaks-allow`
|
||||
proposal, so the audit trail records whether the approval was for a test
|
||||
fixture or a false positive.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Replacing gitleaks or changing the main secret-detection rule set.
|
||||
- Removing support for `# gitleaks:allow`.
|
||||
- Automatically classifying fixture files or false positives.
|
||||
- Adding new supervisor transport or authentication mechanisms.
|
||||
|
||||
## Design
|
||||
|
||||
### Git-gate flow
|
||||
|
||||
`git_gate_render_hook()` emits a `supervise_gitleaks_allow` shell helper.
|
||||
For each incoming ref, git-gate first runs the existing gitleaks command. If
|
||||
that scan passes, it runs:
|
||||
|
||||
```sh
|
||||
gitleaks git \
|
||||
--log-opts="$log_opts" \
|
||||
--no-banner \
|
||||
--redact \
|
||||
--ignore-gitleaks-allow \
|
||||
--report-format=json \
|
||||
--report-path="$report_file" \
|
||||
--exit-code 0
|
||||
```
|
||||
|
||||
The second pass keeps the push path non-interactive while producing a report
|
||||
of findings that would otherwise have been hidden by inline suppression.
|
||||
|
||||
### Supervisor proposal
|
||||
|
||||
When the JSON report contains findings, an embedded Python helper writes a
|
||||
proposal into `SUPERVISE_QUEUE_DIR` using the existing proposal schema. The
|
||||
proposal uses:
|
||||
|
||||
- `tool: "gitleaks-allow"`
|
||||
- a text payload with the ref and each finding's file, line, rule, commit,
|
||||
and redacted code line
|
||||
- a justification that tells the operator to approve only dummy test fixtures
|
||||
or confirmed false positives
|
||||
|
||||
Git-gate then waits for `<proposal-id>.response.json` for
|
||||
`SUPERVISE_GITLEAKS_ALLOW_TIMEOUT_SECONDS`, defaulting to 300 seconds.
|
||||
`approved` and `modified` responses allow the push; `rejected`, invalid
|
||||
responses, invalid timeout configuration, or timeout refuse it.
|
||||
|
||||
### Supervisor UI
|
||||
|
||||
`TOOL_GITLEAKS_ALLOW` is added to the supervisor tool registry. The curses
|
||||
supervisor renders the proposal as text and allows approval or rejection.
|
||||
Modification is unavailable for this proposal type because there is no file
|
||||
patch to apply. Approval from the TUI prompts for a non-empty reason and
|
||||
writes that reason to the response/audit path.
|
||||
|
||||
### Tests
|
||||
|
||||
Unit tests assert that the rendered git-gate hook includes the second gitleaks
|
||||
pass, supervisor queue fields, and fail-closed messages. Supervisor tests cover
|
||||
the new tool constant, proposal archiving, and the required TUI approval
|
||||
reason.
|
||||
@@ -1,4 +1,4 @@
|
||||
# PRD 0060: Commit bottle state to an image
|
||||
# PRD prd-new: Commit bottle state to an image
|
||||
|
||||
- **Status:** Active
|
||||
- **Author:** Claude
|
||||
@@ -1,75 +0,0 @@
|
||||
# PRD prd-new: Install script
|
||||
|
||||
- **Status:** Active
|
||||
- **Author:** didericis
|
||||
- **Created:** 2026-06-06
|
||||
- **Issue:** #197
|
||||
|
||||
## Summary
|
||||
|
||||
Add a proper Python package distribution and a thin `install.sh` bootstrapper so users can install bot-bottle with a single command without cloning the repo.
|
||||
|
||||
## Problem
|
||||
|
||||
There is currently no install path for new users. The only way to run bot-bottle is to clone the repo and invoke `cli.py` directly. This blocks any HN-style public demo: readers want `curl | sh` or `pipx install`, not a manual clone-and-configure flow.
|
||||
|
||||
## Goals / Success Criteria
|
||||
|
||||
- `curl -fsSL <url>/install.sh | sh` (or equivalent) leaves a working `bot-bottle` command on PATH.
|
||||
- Python-native users can install with `pipx install bot-bottle` or `uv tool install bot-bottle`.
|
||||
- `install.sh` validates prerequisites (Python ≥ 3.11, Docker) and exits with a clear message if they are missing. It does not silently install Docker.
|
||||
- `install.sh` runs `bot-bottle doctor` (or equivalent diagnostic) after install to confirm the environment is ready.
|
||||
- The package has no runtime pip dependencies (stdlib-only, matching the existing constraint).
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Bundling a Python runtime or producing a standalone binary.
|
||||
- Automatic Docker installation.
|
||||
- Plugin architecture changes (out of scope; see issue #197 for future direction).
|
||||
- Publishing to PyPI in this PR — the package structure is the deliverable; publishing is a separate step.
|
||||
|
||||
## Design
|
||||
|
||||
### Package structure
|
||||
|
||||
Add a minimal `pyproject.toml` at the repo root:
|
||||
|
||||
```toml
|
||||
[project]
|
||||
name = "bot-bottle"
|
||||
version = "0.1.0"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = []
|
||||
|
||||
[project.scripts]
|
||||
bot-bottle = "bot_bottle.cli:main"
|
||||
```
|
||||
|
||||
The existing `bot_bottle/` package and `cli.py` entry point already contain the logic; this just wires up the standard entry point. `cli.py` may need a small refactor to expose a `main()` callable if it uses `if __name__ == "__main__"` only.
|
||||
|
||||
### `install.sh`
|
||||
|
||||
A thin bootstrapper that:
|
||||
|
||||
1. Checks `python3 --version` ≥ 3.11; exits with instructions if not met.
|
||||
2. Checks `docker info` exits 0; exits with instructions if Docker is not running.
|
||||
3. Installs via `pipx` if available, otherwise falls back to `pip install --user`.
|
||||
4. Runs `bot-bottle doctor` to verify the install.
|
||||
|
||||
The script must be idempotent (safe to re-run) and must not require `sudo`.
|
||||
|
||||
### `bot-bottle doctor`
|
||||
|
||||
A new subcommand that checks and reports:
|
||||
|
||||
- Python version.
|
||||
- Docker daemon reachability.
|
||||
- Whether `~/.bot-bottle/` config directory exists.
|
||||
|
||||
Exits 0 if all checks pass, non-zero otherwise.
|
||||
|
||||
## Decisions
|
||||
|
||||
- `install.sh` is hosted from the repo's raw Gitea URL for now:
|
||||
`https://gitea.dideric.is/didericis/bot-bottle/raw/branch/main/install.sh`.
|
||||
- Should `version` in `pyproject.toml` be driven by a git tag at build time (e.g. via `hatch-vcs`) or kept as a static string? Static is simpler for now.
|
||||
@@ -1,14 +1,14 @@
|
||||
---
|
||||
agent_provider:
|
||||
template: claude
|
||||
# auth_token names the host env var holding the Claude OAuth token. The
|
||||
# provider injects a provider-owned api.anthropic.com egress route that
|
||||
# re-injects this token as the Bearer header; the agent only ever sees a
|
||||
# placeholder CLAUDE_CODE_OAUTH_TOKEN. DLP defaults (token_patterns,
|
||||
# known_secrets outbound; naive_injection_detection inbound) apply to
|
||||
# that route. To scan additional hosts, declare them under egress.routes
|
||||
# with per-route matches/dlp (see README "Egress route fields").
|
||||
auth_token: BOT_BOTTLE_CLAUDE_OAUTH_TOKEN
|
||||
|
||||
egress:
|
||||
routes:
|
||||
- host: api.anthropic.com
|
||||
role: claude_code_oauth
|
||||
auth:
|
||||
scheme: Bearer
|
||||
token_ref: BOT_BOTTLE_CLAUDE_OAUTH_TOKEN
|
||||
---
|
||||
|
||||
Common Claude provider boundary. Drop this file into
|
||||
|
||||
-50
@@ -1,50 +0,0 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
PACKAGE_SPEC="${BOT_BOTTLE_INSTALL_SPEC:-git+https://gitea.dideric.is/didericis/bot-bottle.git}"
|
||||
MIN_PYTHON="3.11"
|
||||
|
||||
say() {
|
||||
printf 'bot-bottle install: %s\n' "$*" >&2
|
||||
}
|
||||
|
||||
die() {
|
||||
say "error: $*"
|
||||
exit 1
|
||||
}
|
||||
|
||||
command -v python3 >/dev/null 2>&1 || die "python3 is required (version ${MIN_PYTHON} or newer)"
|
||||
|
||||
python3 - <<'PY' || die "python3 3.11 or newer is required"
|
||||
import sys
|
||||
|
||||
raise SystemExit(0 if sys.version_info >= (3, 11) else 1)
|
||||
PY
|
||||
|
||||
command -v docker >/dev/null 2>&1 || die "Docker is required; install Docker and start the daemon, then re-run this script"
|
||||
docker info >/dev/null 2>&1 || die "Docker is installed but the daemon is not reachable; start Docker and re-run this script"
|
||||
|
||||
mkdir -p \
|
||||
"${HOME}/.bot-bottle/agents" \
|
||||
"${HOME}/.bot-bottle/bottles" \
|
||||
"${HOME}/.bot-bottle/contrib"
|
||||
|
||||
if command -v pipx >/dev/null 2>&1; then
|
||||
say "installing with pipx"
|
||||
pipx install --force "${PACKAGE_SPEC}"
|
||||
else
|
||||
say "pipx not found; installing with python3 -m pip --user"
|
||||
python3 -m pip install --user --upgrade "${PACKAGE_SPEC}"
|
||||
fi
|
||||
|
||||
if command -v bot-bottle >/dev/null 2>&1; then
|
||||
BOT_BOTTLE_BIN="bot-bottle"
|
||||
elif [ -x "${HOME}/.local/bin/bot-bottle" ]; then
|
||||
BOT_BOTTLE_BIN="${HOME}/.local/bin/bot-bottle"
|
||||
say "using ${BOT_BOTTLE_BIN}; add ${HOME}/.local/bin to PATH for future shells"
|
||||
else
|
||||
die "bot-bottle was installed but is not on PATH"
|
||||
fi
|
||||
|
||||
say "running bot-bottle doctor"
|
||||
"${BOT_BOTTLE_BIN}" doctor
|
||||
@@ -1,27 +0,0 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=68"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "bot-bottle"
|
||||
version = "0.1.0"
|
||||
description = "Self-hosted sandbox for AI coding agents with egress controls"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
license = { text = "Apache-2.0" }
|
||||
dependencies = []
|
||||
|
||||
[project.scripts]
|
||||
bot-bottle = "bot_bottle.cli:main"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
include = ["bot_bottle*"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
bot_bottle = [
|
||||
"Dockerfile.sidecars",
|
||||
"egress_entrypoint.sh",
|
||||
"contrib/claude/Dockerfile",
|
||||
"contrib/codex/Dockerfile",
|
||||
"contrib/pi/Dockerfile",
|
||||
]
|
||||
@@ -1,51 +0,0 @@
|
||||
"""Unit: `bot-bottle doctor` host prerequisite checks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from bot_bottle.cli import doctor
|
||||
|
||||
|
||||
class TestDoctor(unittest.TestCase):
|
||||
def test_success_when_prerequisites_present(self):
|
||||
with tempfile.TemporaryDirectory() as tmp, patch.object(
|
||||
doctor.Path, "home", return_value=Path(tmp),
|
||||
), patch.object(
|
||||
doctor.shutil, "which", return_value="/usr/bin/docker",
|
||||
), patch.object(
|
||||
doctor.subprocess, "run",
|
||||
return_value=MagicMock(returncode=0),
|
||||
):
|
||||
Path(tmp, ".bot-bottle").mkdir()
|
||||
self.assertEqual(0, doctor.cmd_doctor([]))
|
||||
|
||||
def test_missing_config_fails(self):
|
||||
with tempfile.TemporaryDirectory() as tmp, patch.object(
|
||||
doctor.Path, "home", return_value=Path(tmp),
|
||||
), patch.object(
|
||||
doctor.shutil, "which", return_value="/usr/bin/docker",
|
||||
), patch.object(
|
||||
doctor.subprocess, "run",
|
||||
return_value=MagicMock(returncode=0),
|
||||
):
|
||||
self.assertEqual(1, doctor.cmd_doctor([]))
|
||||
|
||||
def test_missing_docker_fails_before_daemon_check(self):
|
||||
with tempfile.TemporaryDirectory() as tmp, patch.object(
|
||||
doctor.Path, "home", return_value=Path(tmp),
|
||||
), patch.object(
|
||||
doctor.shutil, "which", return_value=None,
|
||||
), patch.object(
|
||||
doctor.subprocess, "run",
|
||||
) as run:
|
||||
Path(tmp, ".bot-bottle").mkdir()
|
||||
self.assertEqual(1, doctor.cmd_doctor([]))
|
||||
run.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -301,19 +301,6 @@ class TestSidecarBundleShape(unittest.TestCase):
|
||||
self.assertEqual("bot-bottle-sidecars:latest", sc["image"])
|
||||
self.assertEqual("Dockerfile.sidecars", sc["build"]["dockerfile"])
|
||||
|
||||
def test_bundle_uses_packaged_dockerfile_when_root_missing(self):
|
||||
from bot_bottle.backend.docker import compose as compose_mod
|
||||
|
||||
original = compose_mod._REPO_DIR
|
||||
try:
|
||||
compose_mod._REPO_DIR = "/tmp/does-not-exist"
|
||||
self.assertEqual(
|
||||
"bot_bottle/Dockerfile.sidecars",
|
||||
compose_mod._sidecar_bundle_dockerfile(),
|
||||
)
|
||||
finally:
|
||||
compose_mod._REPO_DIR = original
|
||||
|
||||
def test_bundle_container_name_uses_sidecars_prefix(self):
|
||||
sc = self._render()["services"]["sidecars"]
|
||||
self.assertEqual(f"bot-bottle-sidecars-{SLUG}", sc["container_name"])
|
||||
|
||||
@@ -199,30 +199,6 @@ class TestHookRender(unittest.TestCase):
|
||||
self.assertIn('set -- "$@" --push-option="$opt"', hook)
|
||||
self.assertIn('git push "$@" origin "$refspec"', hook)
|
||||
|
||||
def test_inline_gitleaks_allow_routes_to_supervisor(self):
|
||||
hook = git_gate_render_hook()
|
||||
# First gitleaks runs normally; only if that passes does the
|
||||
# hook ask gitleaks to ignore inline allow comments and report
|
||||
# the suppressed findings for human approval.
|
||||
self.assertIn("--ignore-gitleaks-allow", hook)
|
||||
self.assertIn("--report-format=json", hook)
|
||||
self.assertIn('"tool": "gitleaks-allow"', hook)
|
||||
self.assertIn("SUPERVISE_QUEUE_DIR", hook)
|
||||
self.assertIn("SUPERVISE_BOTTLE_SLUG", hook)
|
||||
self.assertIn("supervisor approved # gitleaks:allow", hook)
|
||||
self.assertIn("supervisor rejected # gitleaks:allow", hook)
|
||||
|
||||
def test_inline_gitleaks_allow_fails_closed_without_supervisor(self):
|
||||
hook = git_gate_render_hook()
|
||||
self.assertIn(
|
||||
"cannot route # gitleaks:allow finding to supervisor; refusing push",
|
||||
hook,
|
||||
)
|
||||
self.assertIn(
|
||||
"supervisor approval timed out for # gitleaks:allow; refusing push",
|
||||
hook,
|
||||
)
|
||||
|
||||
|
||||
class TestAccessHookRender(unittest.TestCase):
|
||||
def test_access_hook_refreshes_origin_on_upload_pack(self):
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
"""Unit: install.sh static contract checks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
class TestInstallScript(unittest.TestCase):
|
||||
def test_shell_syntax(self):
|
||||
result = subprocess.run(
|
||||
["sh", "-n", str(ROOT / "install.sh")],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
self.assertEqual("", result.stderr)
|
||||
self.assertEqual(0, result.returncode)
|
||||
|
||||
def test_contract_phrases(self):
|
||||
script = (ROOT / "install.sh").read_text(encoding="utf-8")
|
||||
self.assertIn("python3", script)
|
||||
self.assertIn("docker info", script)
|
||||
self.assertIn("pipx install --force", script)
|
||||
self.assertIn("pip install --user --upgrade", script)
|
||||
self.assertIn('"${BOT_BOTTLE_BIN}" doctor', script)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,27 +0,0 @@
|
||||
"""Unit: Python package metadata for install script PRD."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tomllib
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
class TestPyproject(unittest.TestCase):
|
||||
def test_console_script_and_no_runtime_dependencies(self):
|
||||
data = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8"))
|
||||
project = data["project"]
|
||||
self.assertEqual("bot-bottle", project["name"])
|
||||
self.assertEqual(">=3.11", project["requires-python"])
|
||||
self.assertEqual([], project["dependencies"])
|
||||
self.assertEqual(
|
||||
"bot_bottle.cli:main",
|
||||
project["scripts"]["bot-bottle"],
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -17,7 +17,6 @@ from bot_bottle.supervise import (
|
||||
STATUS_MODIFIED,
|
||||
STATUS_REJECTED,
|
||||
TOOL_CAPABILITY_BLOCK,
|
||||
TOOL_GITLEAKS_ALLOW,
|
||||
archive_proposal,
|
||||
audit_log_path,
|
||||
list_pending_proposals,
|
||||
@@ -321,7 +320,6 @@ class TestToolConstants(unittest.TestCase):
|
||||
supervise.TOOL_ALLOW,
|
||||
TOOL_CAPABILITY_BLOCK,
|
||||
supervise.TOOL_EGRESS_BLOCK,
|
||||
TOOL_GITLEAKS_ALLOW,
|
||||
supervise.TOOL_LIST_EGRESS_ROUTES,
|
||||
),
|
||||
supervise.TOOLS,
|
||||
|
||||
@@ -19,7 +19,6 @@ from bot_bottle.supervise import (
|
||||
STATUS_MODIFIED,
|
||||
STATUS_REJECTED,
|
||||
TOOL_CAPABILITY_BLOCK,
|
||||
TOOL_GITLEAKS_ALLOW,
|
||||
read_audit_entries,
|
||||
read_response,
|
||||
sha256_hex,
|
||||
@@ -34,7 +33,6 @@ def _proposal(slug: str = "dev", tool: str = TOOL_CAPABILITY_BLOCK) -> Proposal:
|
||||
TOOL_CAPABILITY_BLOCK: "FROM python:3.13\n",
|
||||
supervise.TOOL_ALLOW: "routes:\n - host: example.com\n",
|
||||
supervise.TOOL_EGRESS_BLOCK: "routes:\n - host: example.com\n",
|
||||
TOOL_GITLEAKS_ALLOW: "file: tests/test_fixture.py\nline: 3\n",
|
||||
}
|
||||
payload = payloads.get(tool, "")
|
||||
return Proposal.new(
|
||||
@@ -172,30 +170,6 @@ class TestApproveReject(_FakeHomeMixin, unittest.TestCase):
|
||||
self.assertEqual(STATUS_APPROVED, entries[0].operator_action)
|
||||
self.assertEqual("needed for dev", entries[0].justification)
|
||||
|
||||
def test_approve_gitleaks_allow_leaves_response_for_gate(self):
|
||||
qp = self._enqueue(tool=TOOL_GITLEAKS_ALLOW)
|
||||
supervise_cli.approve(qp, notes="dummy fixture")
|
||||
# Gate polls the queue dir for the response; TUI must not archive it.
|
||||
resp = read_response(qp.queue_dir, qp.proposal.id)
|
||||
self.assertEqual(STATUS_APPROVED, resp.status)
|
||||
self.assertEqual("dummy fixture", resp.notes)
|
||||
self.assertFalse((qp.queue_dir / "processed").exists())
|
||||
|
||||
def test_tui_gitleaks_allow_requires_reason(self):
|
||||
qp = self._enqueue(tool=TOOL_GITLEAKS_ALLOW)
|
||||
with patch.object(supervise_cli, "_prompt", return_value=""):
|
||||
status = supervise_cli._approve_from_tui(None, qp) # type: ignore[arg-type]
|
||||
self.assertEqual("approve aborted (empty reason)", status)
|
||||
self.assertFalse((qp.queue_dir / "processed").exists())
|
||||
|
||||
def test_tui_gitleaks_allow_writes_reason(self):
|
||||
qp = self._enqueue(tool=TOOL_GITLEAKS_ALLOW)
|
||||
with patch.object(supervise_cli, "_prompt", return_value="test fixture"):
|
||||
status = supervise_cli._approve_from_tui(None, qp) # type: ignore[arg-type]
|
||||
self.assertIn("approved gitleaks-allow", status)
|
||||
resp = read_response(qp.queue_dir, qp.proposal.id)
|
||||
self.assertEqual("test fixture", resp.notes)
|
||||
|
||||
|
||||
# class TestCapabilityApplyWiring(_FakeHomeMixin, unittest.TestCase):
|
||||
# # DISABLED — capability_apply functionality is currently commented out.
|
||||
|
||||
Reference in New Issue
Block a user