Compare commits

..

4 Commits

Author SHA1 Message Date
didericis 034f774529 feat(supervise): non-blocking MCP — pending carries proposal id + check-proposal poll tool
test / integration (pull_request) Successful in 10s
tracker-policy-pr / check-pr (pull_request) Successful in 11s
lint / lint (push) Successful in 42s
test / coverage (pull_request) Successful in 39s
test / unit (pull_request) Successful in 1m30s
Closes #412.

The supervise MCP server blocked the agent's tool call polling for the
operator's decision, and on timeout returned `status: pending` with no
proposal id and no way to poll a specific proposal — so the only way to
learn a late decision was to re-propose (a duplicate).

- `handle_tools_call` pending timeout now returns the `proposal_id` and
  points the agent at `check-proposal`.
- New `check-proposal` MCP tool: non-blocking status lookup by proposal id
  (pending | approved | modified | rejected | unknown). Reuses the queue's
  FileNotFoundError semantics; archives a decided proposal exactly like the
  synchronous path, so a pending proposal stays visible to the operator
  until it's both decided and polled.
- `TOOL_CHECK_PROPOSAL` constant, re-exported from supervise; kept out of
  TOOLS since it never becomes a Proposal.tool.

Enforcement is unchanged — the tools only propose policy; the egress proxy
and git-gate still enforce — so returning early opens no hole. Follow-ups
(git-gate reject-requeue, backpressure, notifications, web console) are in
the PRD.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YBCHap11yGAKuKfsehNPaD
2026-07-18 17:06:04 -04:00
didericis 5b359fe8d2 fix(git-gate): scan $new --not --all for every push, not $old..$new
test / unit (push) Successful in 32s
test / integration (push) Successful in 31s
lint / lint (push) Successful in 43s
Update Quality Badges / update-badges (push) Successful in 38s
test / coverage (push) Successful in 40s
The pre-receive hook scanned existing-branch updates with the delta range
$old..$new. On a rebase / non-fast-forward force-push onto an advanced main,
$old is no longer an ancestor of $new, so $old..$new expands to all of main's
new history — including the deliberate sandbox-escape gitleaks fixtures — and
the push is rejected on commits that belong to main, not the branch.

Unify the range on `$new --not --all` for every non-delete push (this is the
deferred open question from PRD 0028, which already applied it to new refs
for #106). It scans only the commits the push introduces and is
security-equivalent: the bare repo's refs come only from trusted upstream
mirror-fetch and gitleaks-gated pushes, so an excluded commit is
already-upstream or already-scanned. It is also more correct for
non-fast-forward pushes, where $old..$new can skip commits off the direct path.

Fixes #421

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S1qRZTJC6qgBsUSjNrBdkX
2026-07-18 16:51:31 -04:00
didericis 015ff52eda fix(cli): exempt backend command from DB migration gate
test / integration (pull_request) Successful in 8s
tracker-policy-pr / check-pr (pull_request) Successful in 9s
test / unit (pull_request) Successful in 31s
test / coverage (pull_request) Successful in 36s
test / integration (push) Successful in 12s
test / unit (push) Successful in 36s
test / coverage (push) Successful in 39s
Update Quality Badges / update-badges (push) Successful in 37s
lint / lint (push) Successful in 2m38s
`backend setup/status/teardown` manage host prerequisites only and never
open the store, but the CLI dispatcher ran the schema-migration gate before
every command. On a non-TTY runner the gate's `Migrate now? [y/N]` prompt
reads EOF and refuses, so `backend status --backend=firecracker` exits 1 —
breaking the Firecracker CI preflight on any host without a pre-migrated DB.

Exempt `backend` from the gate; store-touching commands stay gated.

Fixes #419

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S1qRZTJC6qgBsUSjNrBdkX
2026-07-18 15:27:29 -04:00
didericis-claude 4302678f3e docs(research): expand sandbox landscape with 6 new tools; add agent-tailored policy axis
tracker-policy-pr / check-pr (pull_request) Failing after 6s
Isolation tools added: Cleanroom (Buildkite), container-use (Dagger),
Docker sbx, Anthropic srt.

Governance/pre-action layers added as a separate section: Microsoft
Agent Governance Toolkit (per-agent DID + YAML policy + trust score),
Open Agent Passport (declarative policy + cryptographic audit).

Comparison table: 14 → 14 columns; new Agent-tailored policy row added.
Second addendum covers competitive position on role-tailoring, Docker
sbx as new DX-class competitor, and borrowable ideas (trust-score decay,
live network TUI, cryptographic audit chain).

Discourse note: adds Per-agent role tailoring to "What it covers well"
with competitive comparison table across 9 tools.
2026-07-18 19:11:14 +00:00
21 changed files with 781 additions and 623 deletions
-4
View File
@@ -45,10 +45,6 @@ PROVIDER_TEMPLATES = frozenset({PROVIDER_CLAUDE, PROVIDER_CODEX, PROVIDER_PI})
# forward_host_credentials is enabled. Pipelock must pass these through
# (no TLS MITM) or its header DLP blocks the injected JWT.
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[
"append_file",
"read_prompt_file",
+8 -1
View File
@@ -38,6 +38,13 @@ COMMANDS = {
"supervise": cmd_supervise,
}
# Commands that manage host prerequisites (or are otherwise store-free) and
# must run before — or without — a migrated DB. `backend` provisions/probes
# the host (TAP pool, /dev/kvm, firecracker) and never opens the store, so
# gating it on the schema breaks preflight on a fresh CI runner where stdin
# isn't a TTY and the migration prompt can't be answered.
NO_MIGRATION_COMMANDS = frozenset({"backend"})
def usage() -> None:
sys.stderr.write(f"usage: {PROG} <command> [args...]\n\n")
@@ -80,7 +87,7 @@ def main(argv: list[str] | None = None) -> int:
usage()
die(f"unknown command: {command}")
mgr = StoreManager.instance()
if not mgr.is_migrated():
if command not in NO_MIGRATION_COMMANDS and not mgr.is_migrated():
sys.stderr.write("bot-bottle: database schema is out of date\n")
sys.stderr.write("Migrate now? [y/N] ")
sys.stderr.flush()
+5 -17
View File
@@ -23,9 +23,8 @@ from ...agent_provider import (
provider_startup_args,
)
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 .claude_auth import claude_host_access_token
if TYPE_CHECKING:
@@ -119,6 +118,7 @@ class ClaudeAgentProvider(AgentProvider):
color: str = "",
provider_settings: dict[str, object] | None = None,
) -> AgentProvisionPlan:
del forward_host_credentials, host_env
resolved_guest_env = dict(guest_env or {})
startup_args = provider_startup_args(provider_settings)
guest_home = self.guest_home
@@ -180,24 +180,13 @@ class ClaudeAgentProvider(AgentProvider):
claude_settings,
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(
host="api.anthropic.com",
auth_scheme="Bearer" if (auth_token or forward_host_credentials) else "",
token_ref=cred_token_ref,
auth_scheme="Bearer" if auth_token else "",
token_ref=auth_token,
),)
hidden_env_names: frozenset[str] = frozenset()
if auth_token or forward_host_credentials:
if auth_token:
env_vars["CLAUDE_CODE_OAUTH_TOKEN"] = "egress-placeholder"
hidden_env_names = frozenset({"CLAUDE_CODE_OAUTH_TOKEN"})
@@ -219,7 +208,6 @@ class ClaudeAgentProvider(AgentProvider):
files=tuple(files),
egress_routes=egress_routes,
hidden_env_names=hidden_env_names,
provisioned_env=provisioned_env,
)
def provision_skills(self, plan: "BottlePlan", bottle: "Bottle") -> None:
-114
View File
@@ -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",
]
-2
View File
@@ -30,7 +30,6 @@ if TYPE_CHECKING:
from .manifest import ManifestBottle
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"
@@ -401,7 +400,6 @@ class Egress(ABC):
)
__all__ = [
"CLAUDE_HOST_CREDENTIAL_TOKEN_REF",
"CODEX_HOST_CREDENTIAL_TOKEN_REF",
"EGRESS_HOSTNAME",
"EGRESS_ROUTES_FILENAME",
+18 -12
View File
@@ -419,18 +419,24 @@ PY
while IFS=' ' read -r old new ref; do
[ -z "$ref" ] && continue
[ "$new" = "$zero" ] && continue
if [ "$old" = "$zero" ]; then
# New ref: scan only the commits this push introduces — those
# reachable from $new but not from any ref the gate already has.
# Everything already on the gate arrived via upstream mirror-fetch
# or a previously gitleaks-scanned push, so it's already-upstream
# or already-scanned; re-scanning it (the old `$new` full-ancestry
# range) only resurfaces historical findings and blocks every new
# branch. See PRD 0028 / issue #106.
log_opts="$new --not --all"
else
log_opts="$old..$new"
fi
# Scan only the commits this push introduces — those reachable from
# $new but not from any ref the gate already has. Everything already
# on the gate arrived via upstream mirror-fetch or a previously
# gitleaks-scanned push, so it's already-upstream or already-scanned;
# re-scanning it only resurfaces historical fixture findings.
#
# Applies to both new refs and updates. The old existing-branch range
# `$old..$new` walks commits reachable from the new tip but not the
# *old branch tip*: on a rebase/force-push onto a freshly-advanced
# main that pulls in all of main's new history (incl. the deliberate
# sandbox-escape gitleaks fixtures), blocking the push. `--not --all`
# excludes anything already on the gate regardless of ancestry, so it
# is also correct for non-fast-forward pushes (a rebase can skip
# commits off the direct path). Security-equivalent per PRD 0028's
# analysis: the bare repo's refs come only from trusted upstream
# mirror-fetch or gitleaks-gated pushes.
# See PRD 0028 (open question) / issues #106, #346.
log_opts="$new --not --all"
echo "git-gate: gitleaks scanning $ref ($log_opts)" >&2
if ! gitleaks git --log-opts="$log_opts" --no-banner --redact 1>&2; then
echo "git-gate: gitleaks rejected push to $ref" >&2
+4 -10
View File
@@ -25,9 +25,8 @@ class ManifestAgentProvider:
header, and sets a placeholder CLAUDE_CODE_OAUTH_TOKEN in the agent
so the Claude Code CLI starts.
`forward_host_credentials` forwards the host provider auth token into
the egress sidecar (Codex and Claude). For Codex this reads
`~/.codex/auth.json`; for Claude it reads `~/.claude/.credentials.json`.
`forward_host_credentials` forwards the host Codex auth token into
the egress daemon (Codex only).
"""
template: str = "claude"
@@ -93,15 +92,10 @@ class ManifestAgentProvider:
f"is only supported for built-in 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(
f"bottle '{bottle_name}' agent_provider.forward_host_credentials "
"is only supported for templates 'codex' and 'claude'"
)
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"
"is currently only supported for template 'codex'"
)
settings = _parse_provider_settings(bottle_name, template, d.get("settings"))
return cls(
+2
View File
@@ -47,6 +47,7 @@ from .supervise_types import (
STATUS_MODIFIED,
STATUS_REJECTED,
TOOLS,
TOOL_CHECK_PROPOSAL,
TOOL_EGRESS_ALLOW,
TOOL_EGRESS_BLOCK,
TOOL_EGRESS_TOKEN_ALLOW,
@@ -263,6 +264,7 @@ __all__ = [
"TOOLS",
"EGRESS_FORWARD_PROXY",
"EGRESS_INTROSPECT_URL",
"TOOL_CHECK_PROPOSAL",
"TOOL_EGRESS_ALLOW",
"TOOL_EGRESS_BLOCK",
"TOOL_GITLEAKS_ALLOW",
+122 -9
View File
@@ -2,14 +2,24 @@
Per-bottle MCP server exposing tools the agent calls to propose egress
config changes when stuck. The tools are `egress-allow`,
`egress-block`, and `list-egress-routes`.
`egress-block`, `list-egress-routes`, and `check-proposal`.
Each queued tool call:
Each queued proposal tool call:
1. Validates the proposed file syntactically.
2. Writes a Proposal to the host SQLite database.
3. Blocks polling for a matching Response row.
4. Returns the operator's `{status, notes}` to the agent.
3. Blocks polling for a matching Response row, up to a short grace
window (`SUPERVISE_RESPONSE_TIMEOUT_SECONDS`, default 30s).
4. On a decision within the window, returns the operator's
`{status, notes}`. On timeout, returns `status: pending` **with the
proposal id** and leaves the proposal queued the flow is
non-blocking past the grace window (PRD prd-new / issue #412).
`check-proposal` is the non-blocking companion: given a `proposal_id`
returned by a `pending` response, it reports the current decision
(`pending` | `approved` | `modified` | `rejected`) without re-proposing,
so an approval made out-of-band (e.g. a web review console) can be resumed
without holding an HTTP request open.
One shared server fronts every bottle (PRD 0070) and attributes each
proposal to the calling bottle by source IP, resolved from the orchestrator
@@ -22,7 +32,9 @@ Speaks MCP over HTTP+JSON-RPC. Methods handled:
* `initialize` handshake; returns server info + caps.
* `notifications/initialized` ack-only.
* `tools/list` returns the tool definitions.
* `tools/call` validates, queues, blocks, returns.
* `tools/call` validates, queues, waits out the grace
window, returns (pending past it); or, for
`check-proposal`, a non-blocking status poll.
Everything else returns JSON-RPC error -32601 (method not found).
@@ -232,6 +244,31 @@ TOOL_DEFINITIONS: list[dict[str, object]] = [
),
"inputSchema": _proposal_input_schema(),
},
{
"name": _sv.TOOL_CHECK_PROPOSAL,
"description": (
"Poll a previously queued proposal for the operator's decision "
"WITHOUT blocking or re-proposing. Pass the `proposal_id` you "
"got back when an `egress-allow`/`egress-block` call returned "
"`status: pending`. Returns the current status: `pending` (no "
"decision yet — poll again later), `approved`, `modified`, "
"`rejected`, or `unknown` (no such queued proposal — wrong id, "
"or it was already resolved and read)."
),
"inputSchema": {
"type": "object",
"properties": {
"proposal_id": {
"type": "string",
"description": (
"The proposal id from a `pending` response."
),
},
},
"required": ["proposal_id"],
"additionalProperties": False,
},
},
]
@@ -353,7 +390,7 @@ def handle_tools_call(
deadline=deadline,
)
except TimeoutError:
text = format_pending_response_text(config.response_timeout_seconds)
text = format_pending_response_text(proposal.id, config.response_timeout_seconds)
return {
"content": [{"type": "text", "text": text}],
"isError": False,
@@ -370,6 +407,54 @@ def handle_tools_call(
}
def handle_check_proposal(
params: dict[str, object],
config: ServerConfig,
) -> dict[str, object]:
"""Non-blocking poll of a queued proposal's decision, by id.
Never creates a Proposal (so `check-proposal` isn't in `TOOLS`); it only
reads the queue. Resolution order mirrors the synchronous path's terminal
step a decided proposal is archived here exactly as `handle_tools_call`
archives it after `wait_for_response`, so `pending` proposals stay visible
to the operator until they're both decided *and* polled."""
args_raw = params.get("arguments", {})
if not isinstance(args_raw, dict):
raise _RpcClientError(ERR_INVALID_PARAMS, "tools/call 'arguments' must be an object")
proposal_id = args_raw.get("proposal_id")
if not isinstance(proposal_id, str) or not proposal_id.strip():
raise _RpcClientError(
ERR_INVALID_PARAMS,
"check-proposal: 'proposal_id' is required and must be a non-empty string",
)
proposal_id = proposal_id.strip()
try:
response = _sv.read_response(config.bottle_slug, proposal_id)
except FileNotFoundError:
# No decision yet — distinguish "still queued" from "unknown id".
try:
_sv.read_proposal(config.bottle_slug, proposal_id)
except FileNotFoundError:
return {
"content": [{"type": "text", "text": format_unknown_proposal_text(proposal_id)}],
"isError": True,
}
return {
"content": [{"type": "text", "text": format_still_pending_text(proposal_id)}],
"isError": False,
}
try:
_sv.archive_proposal(config.bottle_slug, proposal_id)
except OSError as e:
raise _RpcInternalError(f"failed to archive proposal: {e}") from e
return {
"content": [{"type": "text", "text": format_response_text(response)}],
"isError": response.status == _sv.STATUS_REJECTED,
}
def format_response_text(response: "_sv.Response") -> str:
"""Pretty-print a Response for the tool's text content. The agent
reads the text and decides whether to retry / give up / surface."""
@@ -382,12 +467,35 @@ def format_response_text(response: "_sv.Response") -> str:
return "\n".join(lines)
def format_pending_response_text(timeout_seconds: float) -> str:
def format_pending_response_text(proposal_id: str, timeout_seconds: float) -> str:
"""Grace-window timeout: the proposal stays queued, and the agent is
told the id so it can `check-proposal` instead of re-proposing."""
return "\n".join([
"status: pending",
f"proposal_id: {proposal_id}",
(
"notes: operator response timed out after "
f"{timeout_seconds:g}s; proposal remains queued"
f"notes: no operator decision within {timeout_seconds:g}s; the "
"proposal remains queued. Poll it (do not re-propose) by calling "
f"`check-proposal` with proposal_id={proposal_id!r}."
),
])
def format_still_pending_text(proposal_id: str) -> str:
return "\n".join([
"status: pending",
f"proposal_id: {proposal_id}",
"notes: still queued; no operator decision yet. Call `check-proposal` again later.",
])
def format_unknown_proposal_text(proposal_id: str) -> str:
return "\n".join([
"status: unknown",
f"proposal_id: {proposal_id}",
(
"notes: no queued proposal with this id for this bottle — the id "
"may be wrong, or the proposal was already resolved and read."
),
])
@@ -482,6 +590,11 @@ class MCPHandler(http.server.BaseHTTPRequestHandler):
# — silently dropping base routes like api.anthropic.com on approval.
if req.params.get("name") == _sv.TOOL_LIST_EGRESS_ROUTES:
return self._resolved_routes_payload()
# `check-proposal` is a non-blocking read of the calling bottle's
# own queue — attributed by source IP like a proposal, but it
# never queues or blocks.
if req.params.get("name") == _sv.TOOL_CHECK_PROPOSAL:
return handle_check_proposal(req.params, self._attributed_config(config))
# Attribute the proposal to the source-IP-resolved bottle, so the one
# shared server queues each bottle's proposal under its own slug.
return handle_tools_call(req.params, self._attributed_config(config))
+5
View File
@@ -20,6 +20,10 @@ TOOL_EGRESS_ALLOW = "egress-allow"
TOOL_GITLEAKS_ALLOW = "gitleaks-allow"
TOOL_EGRESS_TOKEN_ALLOW = "egress-token-allow"
TOOL_LIST_EGRESS_ROUTES = "list-egress-routes"
# Read-only agent tool: poll a queued proposal for the operator's decision
# without blocking or re-proposing. It never becomes a `Proposal.tool` (no
# queue record is created for it), so it is intentionally NOT in `TOOLS`.
TOOL_CHECK_PROPOSAL = "check-proposal"
TOOLS: tuple[str, ...] = (
TOOL_EGRESS_ALLOW,
TOOL_EGRESS_BLOCK,
@@ -156,6 +160,7 @@ __all__ = [
"TOOLS",
"TOOL_EGRESS_ALLOW",
"TOOL_EGRESS_BLOCK",
"TOOL_CHECK_PROPOSAL",
"TOOL_EGRESS_TOKEN_ALLOW",
"TOOL_GITLEAKS_ALLOW",
"TOOL_LIST_EGRESS_ROUTES",
@@ -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.
+125
View File
@@ -0,0 +1,125 @@
# PRD prd-new: Non-blocking supervise (async approval + proposal polling)
- **Status:** Draft
- **Author:** didericis
- **Created:** 2026-07-18
- **Issue:** #412
## Summary
The per-bottle supervise MCP server (`bot_bottle/supervise_server.py`)
answers `tools/call` **synchronously**: it queues the agent's proposal and
blocks the tool call polling for the operator's decision. On timeout it
returns `status: pending` and leaves the proposal queued — but it hands the
agent **no proposal id** and offers **no way to poll a specific pending
proposal**, so the only way to learn the outcome is to re-propose (a
duplicate).
This PRD makes the MCP flow non-blocking and pollable, so an approval can
happen out-of-band (a human taking minutes-to-hours in a review console)
without holding an HTTP request open or wedging the agent:
1. Include the `proposal_id` in the `pending` response.
2. Add a `check-proposal` MCP tool: a non-blocking status lookup by
proposal id.
3. Keep the short synchronous grace window for the common "operator is
right there" fast path.
## Problem
`handle_tools_call``_sv.wait_for_response(...)` blocks up to
`SUPERVISE_RESPONSE_TIMEOUT_SECONDS` (default 30s). Two problems follow:
- **Human latency ≠ tool-call latency.** A real review — rendered diff,
RBAC routing to an approver, someone tapping approve on their phone — is
minutes-to-hours. Holding the MCP request open that long is fragile
(proxy/keepalive timeouts, the mitmproxy egress hop, and the agent
harness's own tool-call timeout, which a long block can trip and stall
the whole turn).
- **No resume path.** The pending fallback already exists, but without a
proposal id and a poll tool the agent can't reconnect to that specific
decision — it re-proposes, duplicating the queue entry.
This is also the precondition for the planned web-console human-review
flow (RBAC, audit retention, mobile) — see issue #412.
**Safety note:** the MCP tools only *propose* policy changes; enforcement
stays at the egress proxy and the git-gate. Returning early on `pending`
therefore opens no hole — the agent still cannot egress or push anything
unapproved.
## Goals / success criteria
- A `pending` MCP response carries the `proposal_id`.
- An agent can call `check-proposal(proposal_id)` and get the current
state (`pending` | `approved` | `modified` | `rejected`) **without
blocking** and **without creating a new proposal**.
- The synchronous fast path (operator approves within the grace window) is
unchanged: the first `tools/call` still returns the decision directly.
- No change to enforcement, attribution (source-IP → bottle), or the
operator-side queue/response schema.
## Non-goals
- The git-gate `pre-receive` path (it is synchronous by nature and cannot
poll — its async variant is reject-fast + re-push; tracked as a
follow-up).
- Backpressure / in-flight-proposal caps.
- MCP server→client notifications (event-driven resume).
- Any web-console UI (this PRD is the protocol groundwork it needs).
## Design
### `pending` response carries the id
`handle_tools_call`'s timeout branch formats the pending text with the
`proposal.id` and a pointer to `check-proposal`, so the agent knows what to
poll.
### `check-proposal` tool
A new read-only MCP tool (`TOOL_CHECK_PROPOSAL = "check-proposal"`),
attributed to the calling bottle by source IP exactly like the proposal
tools. Input: `{ "proposal_id": string }`. Behavior:
1. `read_response(slug, id)`
- **found**: archive the proposal (same terminal step the synchronous
path takes) and return the decision via `format_response_text`;
`isError` iff rejected.
2. **not found**`read_proposal(slug, id)`
- **found**: still queued → return `status: pending`.
- **not found**: unknown id, or already resolved-and-archived (e.g. a
second poll) → return `status: unknown`, `isError: true`.
Both lookups already raise `FileNotFoundError` when absent
(`queue_store.py`), so the handler needs no new store methods. `check-`
`proposal` is the only path (besides the synchronous response) that
archives, so a proposal that times out to `pending` stays visible to the
operator until it is decided and then polled.
### Grace window
Left at the existing 30s default (`SUPERVISE_RESPONSE_TIMEOUT_SECONDS`),
which doubles as the instant-approve fast path. Tuning it down is an
operator setting, not a code change; noted for the console rollout.
## Implementation chunks
1. **(this PR)** `TOOL_CHECK_PROPOSAL` constant; `check-proposal` tool
definition + `handle_check_proposal`; dispatch wiring; `proposal_id` in
the pending text; unit tests. Files: `bot_bottle/supervise_types.py`,
`bot_bottle/supervise.py` (re-export), `bot_bottle/supervise_server.py`,
`tests/unit/test_supervise_server.py`.
2. **(follow-up)** git-gate `pre-receive` reject-fast + re-push.
3. **(follow-up)** per-bottle in-flight-proposal backpressure cap.
4. **(follow-up)** MCP notifications for event-driven resume; web-console
review flow (RBAC, audit retention) on top.
## Open questions
- Should a resolved-but-unpolled proposal auto-archive after some TTL, or
only on poll? (Leaning: only on poll, so a decision is never lost to a
reaper before the agent sees it.)
- Does the agent harness need an explicit "you have a pending proposal"
nudge, or is returning `pending` from the original call enough? (Deferred
to the notifications chunk.)
+258 -25
View File
@@ -14,22 +14,34 @@ 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.
Updated again 2026-07-18: six additional tools added (Cleanroom,
container-use, Docker sbx, Anthropic srt, Microsoft AGT, Open Agent
Passport); an **Agent-tailored policy** row added to the comparison table;
a separate Governance layers section added for AGT and OAP. See the
second addendum at the end.
## Summary
Nine projects surveyed. None duplicate bot-bottle's combination of
local VM-per-bottle isolation (Firecracker microVM on KVM Linux, Apple
Container on macOS — Docker is now only the legacy fallback), a
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:
Fifteen projects surveyed across two categories: isolation/sandbox tools
and governance/pre-action authorization layers (the latter don't provide
VM or container isolation but do per-agent policy enforcement at the
tool-call level). None duplicate bot-bottle's combination of local
VM-per-bottle isolation, a declarative per-role manifest, per-agent
egress allowlist + outbound-content DLP, bottle/agent split, and the
composable `extends:` policy model. Three clusters stand out:
- **Closest neighbours** — agent-safehouse and litterbox: local,
single-user, thin wrappers over an existing OS primitive
(`sandbox-exec`, Podman + Landlock).
- **Different category** — tilde.run (hosted SaaS), boxlite and
microsandbox (microVM libraries for platform builders), CubeSandbox
- **Different category (isolation)** — tilde.run (hosted SaaS), boxlite
and microsandbox (microVM libraries for platform builders), CubeSandbox
(self-hosted multi-tenant microVM service), endo-familiar
(capability-security paradigm, no OS isolation).
- **New: governance/pre-action layers** — Microsoft AGT and Open Agent
Passport (OAP): framework-embedded tool-call interceptors with
per-agent declarative policy. Closest competitors on agent-tailored
policy, but operate at the tool-call level rather than providing
network/filesystem isolation; they complement rather than substitute.
The microVM cluster (matchlock, smolmachines, boxlite, microsandbox,
CubeSandbox) is the most relevant for the v2 isolation discussion in
@@ -210,20 +222,157 @@ claim that the monetization positioning leans on. See the addendum.
- **Maturity**: Open-sourced July 2026 off production Tencent Cloud use;
most-starred project in this set (~10.4k).
### Cleanroom *(added 2026-07-18)*
- **Source**: https://github.com/buildkite/cleanroom
- **License**: Apache 2.0
- **Isolation**: MicroVM — Firecracker on Linux, Virtualization.framework
on macOS. Digest-pinned OCI images.
- **Locality**: Self-hosted server (CI-oriented).
- **Agent integration**: Generic process sandbox; CI-first, not a
Claude/agent wrapper.
- **Config**: `cleanroom.yaml` in the repo being sandboxed defines egress
rules, resources, and network policy. Cleanroom resolves this from the
commit being run.
- **Network policy**: Default-deny + per-repo hostname allowlist (resolved
from DNS answers + destination IP:port). Co-hosted services on the same
IP:port are not distinguished. OIDC-backed auth for remote servers.
- **Credentials**: Host-side only; not injected in-flight but not present
in the VM.
- **Notable**: Policy lives in the *repo being sandboxed*, not in an
agent-role definition — closer to per-repo scoping than per-role.
Supports Docker-inside-sandbox (`services.docker.required: true`), OIDC
authorization, suspend/resume lifecycle.
- **Maturity**: Active Buildkite product.
### container-use *(added 2026-07-18)*
- **Source**: https://github.com/dagger/container-use
- **License**: Apache 2.0
- **Isolation**: Docker container per agent + git worktree per agent.
Containers share the host kernel; stronger than bare host but weaker
than microVM.
- **Locality**: Local.
- **Agent integration**: MCP stdio server — Claude Code, Cursor, Windsurf.
`claude mcp add container-use -- container-use stdio`.
- **Config**: None for security policy. Environments are provisioned on
demand; no allowlist or credential config.
- **Network policy**: Not addressed.
- **Notable**: Per-agent git branches (`container-use/<env_name>`);
parallel agents without filesystem conflict; real-time log visibility
and terminal attach for intervention; git-based review workflow.
Oriented toward parallel development safety, not security containment.
- **Maturity**: Early development, active.
### Docker sbx *(added 2026-07-18)*
- **Source**: Docker proprietary (`sbx` CLI, separate from `docker`).
- **License**: Proprietary.
- **Isolation**: MicroVM (Docker's own implementation) — each session gets
its own kernel, Docker daemon inside the VM, and filesystem.
- **Locality**: Local (macOS and Windows; does not require Docker Desktop).
- **Agent integration**: Explicit wrapper — Claude Code, Codex, Gemini
CLI, Copilot CLI, Kiro. Launches agent inside the VM with
`--dangerously-skip-permissions` by default.
- **Config**: Open / Balanced / Locked Down network presets at launch. No
per-role manifest.
- **Network policy**: Default-deny; preset levels control strictness. TUI
dashboard shows a live log of every outbound connection (allowed and
blocked) with point-and-click allow/block for hosts.
- **Credentials**: OS keychain + host-side proxy injection — API keys
never enter the VM.
- **Notable**: Best DX among microVM tools (one command, works like native
yolo Claude but inside a VM); branch mode creates a git worktree in
`.sbx/`. Network policy is preset-based, not role-declarative.
- **Maturity**: GA 2026.
### Anthropic srt *(added 2026-07-18)*
- **Source**: https://github.com/anthropic-experimental/sandbox-runtime
(`@anthropic-ai/sandbox-runtime` on npm, `sandbox-runtime` on PyPI)
- **License**: Apache 2.0 (experimental).
- **Isolation**: OS-level only — Seatbelt (`sandbox-exec`) on macOS,
bubblewrap on Linux, WFP (Windows Filtering Platform) account-fenced on
Windows. **No container or VM.** Lowest overhead in the set.
- **Locality**: Local.
- **Agent integration**: Claude Code's sandboxed bash tool uses this
internally. Can wrap any arbitrary process (`srt <command>`). Cloud
Claude Code sessions use full microVMs instead.
- **Config**: Programmatic per-invocation — allow/deny path lists for
filesystem; allow/denylist for network (HTTP proxy + SOCKS5).
- **Network policy**: Proxy-based filtering (HTTP + SOCKS5); domain
allowlist/denylist enforced at proxy layer. Custom proxy supported
(e.g. mitmproxy for inspection + audit). Processes that ignore proxy
env vars may bypass filtering on some platforms.
- **Notable**: Cross-platform (macOS/Linux/Windows); wraps any process,
not just agents; no role/manifest concept. Annotated as a research
preview — APIs may change.
- **Maturity**: Early research preview.
## Governance / pre-action authorization layers
These two tools don't provide VM or filesystem isolation; they intercept
tool calls before execution and evaluate them against a per-agent
declarative policy. They are the closest competitors on **agent-tailored
policy** and complement isolation sandboxes rather than substituting for
them.
### Microsoft Agent Governance Toolkit (AGT) *(added 2026-07-18)*
- **Source**: https://github.com/microsoft/agent-governance-toolkit
- **License**: MIT (~3.3k stars, open-sourced April 2, 2026).
- **Isolation**: None (OS/VM). Execution rings (03, inspired by CPU
privilege levels) control what an agent can do at the framework layer.
MCP security gateway treats MCP traffic as an untrusted boundary.
- **Locality**: Embedded in the agent framework (Python, TypeScript, .NET,
Rust, Go; 20+ framework adapters).
- **Agent integration**: Framework-agnostic. Plugs into Semantic Kernel,
AutoGen, and others as a middleware layer.
- **Config**: YAML policy per agent — tools can be `allowed`, `denied`,
`sandboxed`, or routed through an `approval` step. Every action passes
through a governance gate checking: agent DID, trust score, risk tier,
requested tool, action type, and policy rules.
- **Network policy**: Not directly — operates at tool-call level.
- **Credentials**: Per-agent DID (Ed25519 decentralized identifier); agent
does not borrow a human's credentials.
- **Notable**: Dynamic trust score (01,000, behavioral decay) —
privilege follows observed behaviour, not just provisioning. Covers all
10 OWASP Agentic Top 10 risks. Kill switch + SLO monitoring. Sub-ms
policy enforcement.
- **Maturity**: MIT, ~3.3k ⭐, v3.7.0 May 2026.
### Open Agent Passport (OAP) *(added 2026-07-18)*
- **Source**: https://github.com/aporthq/aport-spec ; spec at
https://api.aport.io/spec/spec/oap/oap-spec.md/ ; arXiv 2603.20953
- **License**: Open specification.
- **Isolation**: None. Pre-action hook only — intercepts tool calls
synchronously before execution, evaluates against a cloud-registry
declarative policy, fails closed.
- **Locality**: Local hook + cloud policy registry.
- **Agent integration**: Framework-agnostic; hook pattern.
- **Config**: Declarative policy rules in a cloud registry (evaluated in
order; first failing rule denies). Ed25519-signed, hash-chained audit
records per decision.
- **Network policy**: Not directly.
- **Notable**: 53ms median authorization decision (N=1,000). In an
adversarial testbed ($5,000 bounty, 1,151 sessions), social engineering
succeeded 74.6% of the time under a permissive policy; under a
restrictive OAP policy, 0% success across 879 attempts. Assumes
framework runtime is not compromised.
- **Maturity**: Specification + reference implementation, 2026.
## Comparison table
| Axis | bot-bottle | endo-familiar | litterbox | agent-safehouse | matchlock | tilde.run | boxlite | microsandbox | smolmachines | CubeSandbox |
|---|---|---|---|---|---|---|---|---|---|---|
| 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) |
| Local vs hosted | Local | Local | Local (Linux) | Local (macOS) | Local | Hosted SaaS | Local | Local | Local | Self-hosted (server/cluster) |
| 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 |
| 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) |
| 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 |
| 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) |
| 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 |
| 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 |
| 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 ⭐ |
*Isolation/sandbox tools only. AGT and OAP are governance layers — see their per-project notes above.*
| Axis | bot-bottle | endo-familiar | litterbox | agent-safehouse | matchlock | tilde.run | boxlite | microsandbox | smolmachines | CubeSandbox | Cleanroom | container-use | Docker sbx | Anthropic srt |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 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) | MicroVM (Firecracker / Virt.fw) | Docker container + git worktree | MicroVM (proprietary) | OS-level (Seatbelt / bubblewrap / WFP) — no container |
| Local vs hosted | Local | Local | Local (Linux) | Local (macOS) | Local | Hosted SaaS | Local | Local | Local | Self-hosted (server/cluster) | Self-hosted server | 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 | Apache 2.0 | Apache 2.0 | Proprietary | Apache 2.0 (experimental) |
| 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) | CI / generic process | Claude Code, Cursor, Windsurf (MCP) | Claude Code, Codex, Gemini CLI, Copilot, Kiro | Claude Code (and any process) |
| 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 | Default-deny + per-repo host allowlist (cleanroom.yaml) | Not addressed | Default-deny; Open / Balanced / Locked Down presets; live TUI network panel | Proxy-based allowlist/denylist (HTTP + SOCKS5); custom proxy supported |
| 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) | Yes (server model) | Yes (per-agent containers + worktrees) | Yes | Yes |
| 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 | Per-run + suspend/resume | Per-agent container (ephemeral) | Per-session; branch mode creates git worktree in .sbx/ | Per-invocation |
| 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 | CI-oriented, not a Claude wrapper | MCP server: `claude mcp add container-use -- container-use stdio` | One command: `sbx` wraps claude with `--dangerously-skip-permissions` default | Library/wrapper, not a standalone CLI |
| 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 | cleanroom.yaml in repo | None (no policy config) | Preset levels at launch | Programmatic per-invocation (allow/deny lists) |
| Agent-tailored policy | Yes — bottle/agent split; declarative per-role egress + credentials; composable via `extends:` | Partial — capability model scopes per-agent, but no declarative role manifest | No | Partial — per-agent profile files (Seatbelt); no egress | No | Yes — per-agent DSL RBAC (allow/deny/approve per action/repo/agent) | No | No | No | No — per-sandbox SDK config, not role-scoped | Partial — per-repo cleanroom.yaml, not per-role | No | No — network presets only | No |
| Maturity | Active July 2026 | Research (2022+) | Early (~66 ⭐) | Active (~1.4k ⭐) | Experimental (~574 ⭐) | Private preview | YC, ~4.7k ⭐ | YC, ~6k ⭐, beta | ~3.1k ⭐ | Tencent, prod, ~10.4k ⭐ | Active (Buildkite product) | Early development | GA 2026 | Early research preview |
## What's closest, what's different
@@ -233,11 +382,41 @@ existing OS primitive, low-dep. The split is the isolation primitive —
bot-bottle now defaults to a VM per bottle (Firecracker microVM on KVM
Linux, Apple Container on macOS) with its own DLP-scanning egress proxy,
keeping Docker only as a legacy fallback; agent-safehouse uses
`sandbox-exec`; litterbox
uses Podman + Landlock. matchlock and smolmachines are close on *both* the
policy side (default-deny net, per-host allowlist) and — now that
bot-bottle has moved off containers-by-default — the microVM isolation
primitive.
`sandbox-exec`; litterbox uses Podman + Landlock. matchlock and
smolmachines are close on *both* the policy side (default-deny net,
per-host allowlist) and — now that bot-bottle has moved off
containers-by-default — the microVM isolation primitive.
**New closest on agent-tailored policy.** Two governance tools are the
direct competitors on the "coarse-grained sandbox" axis. **tilde.run**
has had per-agent DSL RBAC since its launch (though it's hosted SaaS).
**Microsoft AGT** is the most serious new entrant: per-agent DID
identity, YAML policy that can allow/deny/sandbox/approve individual tool
calls per agent, and a dynamic behavioural trust score. It operates at
the framework tool-call layer, not the network layer — so it's
complementary to bot-bottle's network/filesystem isolation rather than a
direct substitute, but on the "does this sandbox know what this agent is
for?" question it is the most complete answer in the field. OAP's
pre-action hook pattern achieves similar goals with cryptographic audit
and a 0% adversarial-attack success rate under a restrictive policy.
**New closest on DX.** **Docker sbx** is the first tool in this set that
matches bot-bottle on the "one command, dangerously-skip-permissions safe
by default" DX bar, at microVM isolation strength, with host-side
credential injection. It is proprietary, preset-based (not role-
declarative), and cloud-agent-specific, but it directly competes on the
UX proposition. agent-safehouse was the previous DX peer; Docker sbx
materially raises the bar.
**New closest on repo-scoped policy.** **Cleanroom** (Buildkite) is the
first tool to combine microVM isolation with a declarative egress policy
file — though the policy lives in the repo being sandboxed
(`cleanroom.yaml`), not in an agent-role manifest. That makes it per-
repo rather than per-role: the same Cleanroom config applies to any
agent running in that repo. The distinction matters for bot-bottle's
use case (one developer running multiple agent *roles* with different
egress footprints), but for CI/CD use cases Cleanroom is a direct
alternative.
**Solving a different problem.** tilde.run is hosted SaaS for team /
production agent pipelines with data-versioned rollback — explicitly
@@ -409,3 +588,57 @@ Why it matters anyway:
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.
## Addendum 2026-07-18 (second pass) — agent-tailored policy landscape
The second-pass question was: how novel is bot-bottle's per-agent,
role-tailored sandbox relative to the expanded field?
**The short answer:** on the isolation + network + role-tailoring
combination, bot-bottle remains the only tool in this set. On
role-tailored *policy at the tool-call level*, Microsoft AGT and OAP are
the most complete answers, but they don't provide isolation; they
complement rather than substitute.
**The competitive picture by axis:**
- *Agent-tailored egress (declarative, per-role)* — bot-bottle and
tilde.run. Cleanroom is per-repo, not per-role. Everyone else is
per-session or not addressed.
- *Agent-tailored tool-call policy (declarative, per-agent identity)*
Microsoft AGT (YAML policy + DID identity + trust score), OAP
(declarative policy rules + cryptographic audit). Neither provides
network/filesystem isolation.
- *Composable policy (role overlays)* — bot-bottle (`extends:`). No
other tool surveyed supports composable role-policy inheritance.
- *Isolation + DX (one-command safe yolo)* — bot-bottle and Docker sbx.
Docker sbx is proprietary, preset-based, and cloud-agent-specific;
it's the first DX-class competitor at microVM isolation strength.
**What the HN "coarse-grained" complaint maps to:** The complaint is
that a VM isolates the filesystem but doesn't know if the agent
*should* be sending an email. bot-bottle's bottle/agent split is a
structural answer to this: the bottle manifest declares exactly what
the role can reach, and the sandbox enforces it at the network layer.
Microsoft AGT is the most complete answer at the semantic/tool-call
layer. The gap both leave open is *intent classification* — knowing
whether a permitted action is consistent with the agent's actual task.
See `hn-agent-safety-discourse-july-2026.md` for the blast-radius
analysis.
**Borrowable from new tools:**
- **Microsoft AGT's trust-score decay** — privilege that reflects
observed behaviour rather than static provisioning. Applied to
bot-bottle: a bottle that has triggered DLP alerts or supervise holds
could auto-downgrade its network preset, or flag the session for
closer review. Fits the existing supervise-server architecture.
- **Docker sbx's live network TUI** — real-time per-session view of
allowed and blocked outbound connections with point-and-click
allow/block. `cli.py supervise` is the right surface; adding a
live-connections panel would directly address the "I can't see what
the agent is doing" gap without any backend changes.
- **OAP's cryptographic audit chain** — Ed25519-signed, hash-chained
audit records. Currently bot-bottle logs egress decisions but doesn't
chain them. A tamper-evident audit record per session would be useful
for the compliance use case the CubeSandbox positioning targets.
@@ -204,6 +204,43 @@ 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.
**Per-agent role tailoring (the "coarse-grained sandbox" complaint)**
The Feb 2026 HN thread that argued "sandboxes are too coarse-grained"
was pointing at a real gap: a VM isolates the filesystem but doesn't
know whether an agent *should* be sending email or calling an external
API. bot-bottle's bottle/agent split is a structural answer at the
network layer — the bottle manifest declares exactly what each role can
reach (which hosts, which paths, which HTTP methods), and the egress
scanner enforces it. A `gitea-dev` bottle that only lists
`gitea.dideric.is` and `api.anthropic.com` structurally cannot send
email or reach AWS, not because the model was told not to, but because
those routes don't exist.
The `extends:` composition model means provider-level policy (the Claude
auth route) lives in one base bottle and role-specific overlays are
stacked on top — no duplication, and changing the base propagates to all
derived roles.
Competitive position on this axis (from `agent-sandbox-landscape.md`):
| Tool | Agent-tailored policy |
|---|---|
| **bot-bottle** | Yes — declarative per-role manifest; `extends:` composition; egress + credentials scoped to role |
| **tilde.run** | Yes — per-agent DSL RBAC (allow/deny/approve per action/repo/agent), but hosted SaaS |
| **Microsoft AGT** | Yes — YAML policy + per-agent DID + trust score, but tool-call level only (no network isolation) |
| **OAP** | Yes — declarative pre-action policy + cryptographic audit, but no isolation |
| **Cleanroom** | Partial — per-repo `cleanroom.yaml`, not per-role |
| **Docker sbx** | No — network presets only |
| **Anthropic srt** | No — programmatic per-invocation |
| **matchlock / smolmachines / microsandbox** | No |
| **agent-safehouse** | Partial — per-agent Seatbelt profiles; no egress |
Two takeaways: bot-bottle and tilde.run are the only isolation tools
with declarative role-tailored policy; Microsoft AGT and OAP are the
closest competitors on role-tailoring but operate at the tool-call layer
without network/filesystem isolation — complementary, not substitutes.
**Outbound exfiltration (any injection class)**
Whatever triggers the agent — README injection, Agentjacking, MCP
+1 -66
View File
@@ -9,15 +9,11 @@ import unittest
from pathlib import Path
from bot_bottle.agent_provider import (
CLAUDE_HOST_CREDENTIAL_HOSTS,
CODEX_HOST_CREDENTIAL_HOSTS,
build_agent_provision_plan,
prompt_args,
)
from bot_bottle.egress import (
CLAUDE_HOST_CREDENTIAL_TOKEN_REF,
CODEX_HOST_CREDENTIAL_TOKEN_REF,
)
from bot_bottle.egress import CODEX_HOST_CREDENTIAL_TOKEN_REF
def _jwt(exp: int) -> str:
@@ -296,67 +292,6 @@ class TestAgentProviderRuntime(unittest.TestCase):
)
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):
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
plan = build_agent_provision_plan(
+55
View File
@@ -95,5 +95,60 @@ class TestMainDispatch(unittest.TestCase):
self.assertEqual(130, main(["x"]))
class TestMigrationGate(unittest.TestCase):
"""The dispatcher's schema-migration gate (cli/__init__.py)."""
def setUp(self) -> None:
# Force the "schema out of date" branch for every test here.
patcher = patch.object(StoreManager, "is_migrated", return_value=False)
patcher.start()
self.addCleanup(patcher.stop)
self.addCleanup(StoreManager.reset)
def test_store_command_blocks_when_stdin_cannot_confirm(self) -> None:
# Non-TTY stdin at EOF (as in CI): the [y/N] prompt reads "" and the
# command is refused rather than migrating silently.
ran: list[bool] = []
def handler(_rest: list[str]) -> int:
ran.append(True)
return 0
with patch.dict(climod.COMMANDS, {"list": handler}), \
patch("sys.stdin", io.StringIO("")), \
patch("sys.stderr", io.StringIO()):
self.assertEqual(1, main(["list"]))
self.assertEqual([], ran, "gated command must not dispatch")
def test_backend_command_skips_gate(self) -> None:
# `backend` provisions/probes the host and never opens the store, so
# it must run even on an unmigrated DB with unanswerable stdin.
ran: list[bool] = []
def handler(_rest: list[str]) -> int:
ran.append(True)
return 0
with patch.dict(climod.COMMANDS, {"backend": handler}), \
patch("sys.stdin", io.StringIO("")), \
patch("sys.stderr", io.StringIO()):
self.assertEqual(0, main(["backend", "status"]))
self.assertEqual([True], ran, "exempt command must dispatch")
def test_store_command_migrates_on_confirmation(self) -> None:
migrated: list[bool] = []
def handler(_rest: list[str]) -> int:
return 0
with patch.dict(climod.COMMANDS, {"list": handler}), \
patch.object(StoreManager, "migrate",
side_effect=lambda: migrated.append(True)), \
patch("sys.stdin", io.StringIO("y\n")), \
patch("sys.stderr", io.StringIO()):
self.assertEqual(0, main(["list"]))
self.assertEqual([True], migrated, "confirmed gate must migrate")
if __name__ == "__main__":
unittest.main()
-186
View File
@@ -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()
+9 -7
View File
@@ -168,16 +168,18 @@ class TestHookRender(unittest.TestCase):
# Stdin is buffered to a tempfile so both phases can re-read.
self.assertIn("refs_file=$(mktemp)", hook)
def test_new_ref_scan_scoped_to_incoming_commits(self):
# A new branch (old=all-zeros) must scan only commits new to the
# gate, not the full ancestry — otherwise historical findings
# block every new-branch push (PRD 0028 / issue #106).
def test_scan_scoped_to_incoming_commits(self):
# Every non-delete push scans only commits new to the gate, not
# the full ancestry and not the `$old..$new` delta — otherwise
# historical fixtures block new-branch pushes (PRD 0028 / #106)
# and a rebase/force-push onto an advanced main drags in main's
# history incl. the sandbox-escape fixtures (#346).
hook = git_gate_render_hook()
self.assertIn('log_opts="$new --not --all"', hook)
# The old over-broad full-ancestry range must be gone.
# Neither the full-ancestry range nor the ancestry-blind delta
# range may survive.
self.assertNotIn('log_opts="$new"', hook)
# Existing-branch delta scan is unchanged.
self.assertIn('log_opts="$old..$new"', hook)
self.assertNotIn('log_opts="$old..$new"', hook)
def test_forward_ssh_is_non_interactive_and_bounded(self):
# No prompt (BatchMode) and a connect timeout, so an unreachable
+1 -9
View File
@@ -80,19 +80,11 @@ class TestAgentProviderHostCredentials(unittest.TestCase):
"forward_host_credentials": "yes",
})
def test_forward_host_credentials_allowed_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):
def test_forward_host_credentials_rejected_for_claude(self):
with self.assertRaises(ManifestError):
_provider_config_bottle({
"template": "claude",
"forward_host_credentials": True,
"auth_token": "SOME_TOKEN",
})
def test_auth_token_defaults_empty(self):
+2 -14
View File
@@ -86,22 +86,10 @@ class TestAgentProviderValidation(unittest.TestCase):
"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):
ManifestAgentProvider.from_dict(
"b", {"forward_host_credentials": True, "template": "pi"}
)
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"}
"b", {"forward_host_credentials": True, "template": "claude"}
)
def test_valid_claude_auth_token(self) -> None:
+129 -1
View File
@@ -32,7 +32,9 @@ from bot_bottle.supervise_server import (
_RpcError,
_RpcInternalError,
_response_timeout_from_env,
format_pending_response_text,
format_response_text,
handle_check_proposal,
handle_initialize,
handle_tools_call,
handle_tools_list,
@@ -218,6 +220,7 @@ class TestHandleToolsList(unittest.TestCase):
_sv.TOOL_EGRESS_ALLOW,
_sv.TOOL_EGRESS_BLOCK,
_sv.TOOL_LIST_EGRESS_ROUTES,
_sv.TOOL_CHECK_PROPOSAL,
]),
sorted(names),
)
@@ -484,9 +487,10 @@ class TestFormatResponseText(unittest.TestCase):
class TestFormatPendingResponseText(unittest.TestCase):
def test_formats_timeout_message(self):
text = supervise_server.format_pending_response_text(12.5)
text = supervise_server.format_pending_response_text("prop-9", 12.5)
self.assertIn("status: pending", text)
self.assertIn("12.5s", text)
self.assertIn("proposal_id: prop-9", text)
# --- End-to-end HTTP sanity ------------------------------------------------
@@ -685,5 +689,129 @@ class TestResolvedRoutesPayload(unittest.TestCase):
_handler(None)._resolved_routes_payload()
class TestNonBlockingSupervise(unittest.TestCase):
"""PRD prd-new / issue #412: pending responses carry the proposal id, and
`check-proposal` polls a queued proposal without blocking or re-proposing."""
_ROUTES = "routes:\n - host: example.com\n"
def setUp(self):
self._tmp = tempfile.TemporaryDirectory(prefix="supervise-nonblock-test.")
self._home_patch = use_bottle_root(Path(self._tmp.name) / ".bot-bottle")
self.config = ServerConfig(bottle_slug="dev")
_qs.QueueStore("dev").migrate()
_as.AuditStore().migrate()
def tearDown(self):
self._home_patch()
self._tmp.cleanup()
def _seed_proposal(self) -> "_sv.Proposal":
p = _sv.Proposal.new(
bottle_slug="dev",
tool=_sv.TOOL_EGRESS_ALLOW,
proposed_file=self._ROUTES,
justification="need example.com",
current_file_hash=_sv.sha256_hex(self._ROUTES),
)
_sv.write_proposal(p)
return p
def _check(self, proposal_id: str) -> dict[str, object]:
return handle_check_proposal({"arguments": {"proposal_id": proposal_id}}, self.config)
# --- pending response carries the id ---
def test_pending_text_includes_id_and_pointer(self):
text = format_pending_response_text("abc-123", 30.0)
self.assertIn("status: pending", text)
self.assertIn("proposal_id: abc-123", text)
self.assertIn("check-proposal", text)
def test_tools_call_timeout_returns_pending_with_id_and_stays_queued(self):
# No responder → the grace window expires → pending, not blocked forever.
result = handle_tools_call(
{
"name": _sv.TOOL_EGRESS_ALLOW,
"arguments": {"routes_yaml": self._ROUTES, "justification": "x"},
},
ServerConfig(bottle_slug="dev", response_timeout_seconds=0.05),
)
self.assertFalse(result["isError"]) # type: ignore[index]
text = result["content"][0]["text"] # type: ignore[index]
self.assertIn("status: pending", text)
pending = _sv.list_pending_proposals("dev")
self.assertEqual(1, len(pending)) # still queued, not archived
self.assertIn(pending[0].id, text) # agent got the id to poll
# --- check-proposal poll ---
def test_check_returns_approved_and_archives(self):
p = self._seed_proposal()
_sv.write_response("dev", _sv.Response(proposal_id=p.id, status=_sv.STATUS_APPROVED, notes="ok"))
result = self._check(p.id)
self.assertFalse(result["isError"])
text = result["content"][0]["text"] # type: ignore[index]
self.assertIn("status: approved", text)
self.assertIn("notes: ok", text)
with self.assertRaises(FileNotFoundError): # archived on read
_sv.read_proposal("dev", p.id)
def test_check_rejected_sets_isError(self):
p = self._seed_proposal()
_sv.write_response("dev", _sv.Response(proposal_id=p.id, status=_sv.STATUS_REJECTED, notes="no"))
result = self._check(p.id)
self.assertTrue(result["isError"])
self.assertIn("status: rejected", result["content"][0]["text"]) # type: ignore[index]
def test_check_pending_when_no_decision_yet(self):
p = self._seed_proposal()
result = self._check(p.id)
self.assertFalse(result["isError"])
text = result["content"][0]["text"] # type: ignore[index]
self.assertIn("status: pending", text)
self.assertIn(p.id, text)
self.assertEqual(1, len(_sv.list_pending_proposals("dev"))) # not archived
def test_check_unknown_id_is_error(self):
result = self._check("no-such-proposal")
self.assertTrue(result["isError"])
self.assertIn("status: unknown", result["content"][0]["text"]) # type: ignore[index]
def test_check_missing_id_raises(self):
with self.assertRaises(_RpcClientError) as cm:
handle_check_proposal({"arguments": {}}, self.config)
self.assertEqual(ERR_INVALID_PARAMS, cm.exception.code)
def test_check_empty_id_raises(self):
with self.assertRaises(_RpcClientError) as cm:
handle_check_proposal({"arguments": {"proposal_id": " "}}, self.config)
self.assertEqual(ERR_INVALID_PARAMS, cm.exception.code)
def test_check_arguments_must_be_object(self):
with self.assertRaises(_RpcClientError) as cm:
handle_check_proposal({"arguments": []}, self.config)
self.assertEqual(ERR_INVALID_PARAMS, cm.exception.code)
def test_full_nonblocking_round_trip(self):
# 1. tools/call times out → pending with id
result = handle_tools_call(
{
"name": _sv.TOOL_EGRESS_ALLOW,
"arguments": {"routes_yaml": self._ROUTES, "justification": "x"},
},
ServerConfig(bottle_slug="dev", response_timeout_seconds=0.05),
)
pid = _sv.list_pending_proposals("dev")[0].id
self.assertIn(pid, result["content"][0]["text"]) # type: ignore[index]
# 2. operator decides out-of-band
_sv.write_response("dev", _sv.Response(proposal_id=pid, status=_sv.STATUS_APPROVED, notes="ok"))
# 3. agent resumes by polling — no re-proposing
poll = self._check(pid)
self.assertFalse(poll["isError"])
self.assertIn("status: approved", poll["content"][0]["text"]) # type: ignore[index]
self.assertEqual([], _sv.list_pending_proposals("dev")) # resolved + archived
if __name__ == "__main__":
unittest.main()