Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 78d72264c8 | |||
| 0f62d70b81 | |||
| c8ef5a5638 |
@@ -22,7 +22,10 @@ jobs:
|
|||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
# No actions/setup-python: canaries are stdlib unittest on the image's
|
- name: Set up Python
|
||||||
# system Python 3.12 (older act_runner mishandles setup-python's PATH).
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
|
||||||
- name: Run canaries
|
- name: Run canaries
|
||||||
run: python3 -m unittest discover -t . -s tests/canaries -v
|
run: python3 -m unittest discover -t . -s tests/canaries -v
|
||||||
|
|||||||
+10
-20
@@ -13,30 +13,20 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
# No actions/setup-python: the runner image already ships Python 3.12,
|
- name: Set up Python
|
||||||
# and older act_runner engines mishandle setup-python's PATH. Install
|
uses: actions/setup-python@v4
|
||||||
# into the ephemeral job container's system Python — the pylint/pyright
|
with:
|
||||||
# console scripts land on /usr/local/bin (on PATH) so the steps below
|
python-version: "3.12"
|
||||||
# still resolve. --break-system-packages is safe: the container is
|
|
||||||
# disposable.
|
|
||||||
- name: Install dev dependencies
|
- name: Install dev dependencies
|
||||||
run: python3 -m pip install --break-system-packages -r requirements-dev.txt
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install -r requirements-dev.txt
|
||||||
|
|
||||||
- name: Run pylint
|
- name: Run pylint
|
||||||
run: |
|
run: |
|
||||||
# Pylint's normal exit code is nonzero for any emitted finding,
|
# Run pylint on all Python files in the repo
|
||||||
# regardless of --fail-under. Preserve the full report but enforce
|
find . -name '*.py' -not -path './.venv/*' -not -path './.git/*' | xargs pylint --fail-under=8.0
|
||||||
# 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: |
|
||||||
|
|||||||
@@ -37,8 +37,11 @@ jobs:
|
|||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
# No actions/setup-python: the inline script is stdlib-only on the
|
- name: Set up Python
|
||||||
# image's system Python 3.12 (older act_runner mishandles its PATH).
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
|
||||||
- name: Configure git
|
- name: Configure git
|
||||||
run: |
|
run: |
|
||||||
git config user.name "github-actions[bot]"
|
git config user.name "github-actions[bot]"
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
name: tracker-policy-pr
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
types: [opened, edited, reopened, synchronized, 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
|
|
||||||
@@ -20,18 +20,21 @@ jobs:
|
|||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
# No actions/setup-python: the runner image ships Python 3.12 and older
|
- name: Set up Python
|
||||||
# act_runner engines mishandle setup-python's PATH. Install into the
|
uses: actions/setup-python@v4
|
||||||
# ephemeral job container's system Python (--break-system-packages is
|
with:
|
||||||
# safe because the container is disposable).
|
python-version: '3.12'
|
||||||
|
|
||||||
- name: Install dev dependencies
|
- name: Install dev dependencies
|
||||||
run: python3 -m pip install --break-system-packages -r requirements-dev.txt
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install -r requirements-dev.txt
|
||||||
|
|
||||||
- name: Run coverage and extract percentage
|
- name: Run coverage and extract percentage
|
||||||
id: coverage
|
id: coverage
|
||||||
run: |
|
run: |
|
||||||
python3 -m coverage run -m unittest discover -t . -s tests/unit > /dev/null 2>&1 || true
|
python -m coverage run -m unittest discover -t . -s tests/unit > /dev/null 2>&1 || true
|
||||||
PERCENT=$(python3 -m coverage report 2>/dev/null | grep '^TOTAL' | grep -oP '\d+(?=%)' | tail -1)
|
PERCENT=$(python -m coverage report 2>/dev/null | grep '^TOTAL' | grep -oP '\d+(?=%)' | tail -1)
|
||||||
echo "percent=$PERCENT" >> $GITHUB_OUTPUT
|
echo "percent=$PERCENT" >> $GITHUB_OUTPUT
|
||||||
echo "Coverage: $PERCENT%"
|
echo "Coverage: $PERCENT%"
|
||||||
|
|
||||||
@@ -42,7 +45,7 @@ jobs:
|
|||||||
# the single source of truth in scripts/critical-modules.txt; every
|
# the single source of truth in scripts/critical-modules.txt; every
|
||||||
# core module is unit-tested, so the unit-only run is accurate for it.
|
# core module is unit-tested, so the unit-only run is accurate for it.
|
||||||
INCLUDE=$(grep -vE '^[[:space:]]*(#|$)' scripts/critical-modules.txt | paste -sd, -)
|
INCLUDE=$(grep -vE '^[[:space:]]*(#|$)' scripts/critical-modules.txt | paste -sd, -)
|
||||||
PERCENT=$(python3 -m coverage report --include="$INCLUDE" 2>/dev/null | grep '^TOTAL' | grep -oP '\d+(?=%)' | tail -1)
|
PERCENT=$(python -m coverage report --include="$INCLUDE" 2>/dev/null | grep '^TOTAL' | grep -oP '\d+(?=%)' | tail -1)
|
||||||
echo "percent=$PERCENT" >> $GITHUB_OUTPUT
|
echo "percent=$PERCENT" >> $GITHUB_OUTPUT
|
||||||
echo "Core coverage: $PERCENT%"
|
echo "Core coverage: $PERCENT%"
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
# bot-bottle
|
# bot-bottle
|
||||||
|
|
||||||
[](https://gitea.dideric.is/didericis/bot-bottle/actions?workflow=test.yml)
|
[](https://gitea.dideric.is/didericis/bot-bottle/actions?workflow=test.yml)
|
||||||
[](https://coverage.readthedocs.io/)
|
[](https://coverage.readthedocs.io/)
|
||||||
[](https://gitea.dideric.is/didericis/bot-bottle/src/branch/main/docs/decisions/0004-coverage-policy.md)
|
[](https://gitea.dideric.is/didericis/bot-bottle/src/branch/main/docs/decisions/0004-coverage-policy.md)
|
||||||
|
|
||||||
**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.
|
||||||
|
|
||||||
@@ -171,15 +171,6 @@ 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.
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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",
|
|
||||||
]
|
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -6,49 +6,27 @@ 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. CubeSandbox added 2026-07-18 (see its
|
Research conducted 2026-05-11.
|
||||||
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.
|
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
Nine projects surveyed. None duplicate bot-bottle's combination of
|
Eight projects surveyed. None duplicate bot-bottle's combination of
|
||||||
local VM-per-bottle isolation (Firecracker microVM on KVM Linux, Apple
|
local Docker, declarative JSON manifest, per-agent egress allowlist via
|
||||||
Container on macOS — Docker is now only the legacy fallback), a
|
pipelock, and bottle/agent split. Two clusters stand out:
|
||||||
declarative JSON manifest, per-agent egress allowlist + outbound-content
|
|
||||||
DLP via bot-bottle's own egress scanner (plus gitleaks secret-scanning on
|
|
||||||
git push), and bottle/agent split. Two 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** — tilde.run (hosted SaaS), boxlite and
|
||||||
microsandbox (microVM libraries for platform builders), CubeSandbox
|
microsandbox (microVM libraries for platform builders), endo-familiar
|
||||||
(self-hosted multi-tenant microVM service), endo-familiar
|
|
||||||
(capability-security paradigm, no OS isolation).
|
(capability-security paradigm, no OS isolation).
|
||||||
|
|
||||||
The microVM cluster (matchlock, smolmachines, boxlite, microsandbox,
|
The microVM cluster (matchlock, smolmachines, boxlite, microsandbox) is
|
||||||
CubeSandbox) is the most relevant for the v2 isolation discussion in
|
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 microVMs are **now bot-bottle's default backend**
|
ergonomic enough that a `"runtime": "microvm"` option on a bottle is now
|
||||||
(Firecracker on KVM Linux, Apple Container on macOS), with Docker kept
|
plausible without a heavy stack.
|
||||||
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
|
||||||
|
|
||||||
@@ -177,105 +155,67 @@ claim that the monetization positioning leans on. See the addendum.
|
|||||||
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.5–50× 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).
|
|
||||||
|
|
||||||
## Comparison table
|
## Comparison table
|
||||||
|
|
||||||
| Axis | bot-bottle | endo-familiar | litterbox | agent-safehouse | matchlock | tilde.run | boxlite | microsandbox | smolmachines | CubeSandbox |
|
| Axis | bot-bottle | endo-familiar | litterbox | agent-safehouse | matchlock | tilde.run | boxlite | microsandbox | smolmachines |
|
||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|---|---|---|---|---|---|---|---|---|---|
|
||||||
| 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) |
|
| 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) |
|
||||||
| Local vs hosted | Local | Local | Local (Linux) | Local (macOS) | Local | Hosted SaaS | Local | Local | Local | Self-hosted (server/cluster) |
|
| 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 | Apache 2.0 |
|
| 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 |
|
||||||
| 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) |
|
| Agent target | Claude Code | Generic (demo) | Generic | Multi-agent wrapper | Generic (+ Claude/OpenAI SDKs) | Claude focus | Generic | Claude + Cursor (MCP/Skills) | Generic (AGENTS.md) |
|
||||||
| 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 |
|
| 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 |
|
||||||
| 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) |
|
| Parallel agents | Yes (one bottle per agent) | n/a | Not addressed | One at a time | Multiple VMs | Yes (dashboard) | SDK-level | SDK-level | Architectural |
|
||||||
| 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 |
|
| Config | JSON manifest (bottles + agents) | Programmatic refs | CLI wizard | Profile files / shell fns | CLI / SDK | DSL + CLI + SDK | SDK | CLI / SDK / MCP | TOML Smolfile |
|
||||||
| 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 |
|
| Maturity | Active May 2026 | Research (2022+) | Early (~66 ⭐) | Active (~1.4k ⭐) | Experimental (~574 ⭐) | Private preview | YC, ~4.7k ⭐ | YC, ~6k ⭐, beta | ~3.1k ⭐ |
|
||||||
| 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 |
|
|
||||||
| Maturity | Active May 2026 | Research (2022+) | Early (~66 ⭐) | Active (~1.4k ⭐) | Experimental (~574 ⭐) | Private preview | YC, ~4.7k ⭐ | YC, ~6k ⭐, beta | ~3.1k ⭐ | Tencent, prod, ~10.4k ⭐ |
|
|
||||||
|
|
||||||
## 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 now defaults to a VM per bottle (Firecracker microVM on KVM
|
bot-bottle uses Docker + pipelock egress (plus gVisor where
|
||||||
Linux, Apple Container on macOS) with its own DLP-scanning egress proxy,
|
available); agent-safehouse uses `sandbox-exec`; litterbox uses Podman +
|
||||||
keeping Docker only as a legacy fallback; agent-safehouse uses
|
Landlock. matchlock and smolmachines are spiritually close on the
|
||||||
`sandbox-exec`; litterbox
|
*policy* side (default-deny net, per-host allowlist) but use microVMs
|
||||||
uses Podman + Landlock. matchlock and smolmachines are close on *both* the
|
instead of containers.
|
||||||
policy side (default-deny net, per-host allowlist) and — now that
|
|
||||||
bot-bottle has moved off containers-by-default — the microVM isolation
|
|
||||||
primitive.
|
|
||||||
|
|
||||||
**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,
|
opposite to bot-bottle's "infrastructure I control" goal. boxlite and
|
||||||
microsandbox, and CubeSandbox are infrastructure libraries/services aimed
|
microsandbox are infrastructure libraries aimed at platform builders
|
||||||
at platform builders embedding sandboxes into agent frameworks; they
|
embedding sandboxes into agent frameworks; they would be a *backend*
|
||||||
would be a *backend* bot-bottle could call, not a competitor to its
|
bot-bottle could call, not a competitor to its manifest layer.
|
||||||
manifest layer. endo-familiar is in a different paradigm entirely:
|
endo-familiar is in a different paradigm entirely: capability passing
|
||||||
capability passing rather than kernel boundaries.
|
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 (own egress scanner).
|
- Default-deny egress with a per-agent allowlist (pipelock).
|
||||||
- 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 /
|
Ideas worth considering, without abandoning the Python-stdlib-first / local-Docker
|
||||||
local, single-operator stance:
|
stance:
|
||||||
|
|
||||||
1. **Per-use SSH key confirmation** (from litterbox). Even with
|
1. **Per-use SSH key confirmation** (from litterbox). Even with
|
||||||
KnownHostKey pinning and the egress DLP scanner, a wrapper SSH agent that
|
KnownHostKey pinning and pipelock egress, 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). The egress scanner
|
2. **In-flight secret injection** (from matchlock). Pipelock already
|
||||||
already does allowlisting and DLP; teaching it to *inject* tokens at
|
does egress 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 egress-proxy architecture.
|
Fits the existing pipelock architecture.
|
||||||
3. **MicroVM backend** — ~~on the radar~~ **shipped since this survey.**
|
3. **MicroVM backend as an opt-in bottle type** — already on the radar
|
||||||
microVMs are now bot-bottle's default (Firecracker on KVM Linux, Apple
|
in `stronger-isolation-alternatives.md`. microsandbox, smolmachines,
|
||||||
Container on macOS); Docker is the legacy fallback. The libkrun / Apple
|
and matchlock all show that libkrun + Apple's
|
||||||
Virtualization.framework ergonomics that microsandbox, smolmachines,
|
Virtualization.framework is ergonomic enough that a
|
||||||
and matchlock demonstrated turned out to be enough to make it the
|
`"runtime": "microvm"` field on a bottle is plausible without a heavy
|
||||||
default rather than an opt-in.
|
stack.
|
||||||
|
|
||||||
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
|
||||||
@@ -290,122 +230,3 @@ 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.
|
|
||||||
|
|||||||
@@ -1,308 +0,0 @@
|
|||||||
# 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 June–July 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 June–July,
|
|
||||||
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 June–July 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 2–4 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 June–July
|
|
||||||
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.
|
|
||||||
|
|
||||||
**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)
|
|
||||||
@@ -1,204 +0,0 @@
|
|||||||
# 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,135 +0,0 @@
|
|||||||
#!/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())
|
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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()
|
|
||||||
@@ -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):
|
||||||
|
|||||||
@@ -86,22 +86,10 @@ 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"}
|
"b", {"forward_host_credentials": True, "template": "claude"}
|
||||||
)
|
|
||||||
|
|
||||||
def test_forward_creds_claude_allowed(self) -> None:
|
|
||||||
p = ManifestAgentProvider.from_dict(
|
|
||||||
"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:
|
||||||
|
|||||||
@@ -1,62 +0,0 @@
|
|||||||
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()
|
|
||||||
Reference in New Issue
Block a user