Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 25155a1d5a | |||
| aa44feea02 | |||
| f2e2572a40 | |||
| 7069fa225d | |||
| aa224c4381 |
@@ -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",
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.)
|
||||
@@ -6,27 +6,49 @@ general AI-agent sandbox / containment projects — some Claude-specific,
|
||||
some agent-agnostic, some hosted SaaS — and contrasts them with
|
||||
bot-bottle's design.
|
||||
|
||||
Research conducted 2026-05-11.
|
||||
Research conducted 2026-05-11. CubeSandbox added 2026-07-18 (see its
|
||||
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
|
||||
|
||||
Eight projects surveyed. None duplicate bot-bottle's combination of
|
||||
local Docker, declarative JSON manifest, per-agent egress allowlist via
|
||||
pipelock, and bottle/agent split. Two clusters stand out:
|
||||
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:
|
||||
|
||||
- **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), endo-familiar
|
||||
microsandbox (microVM libraries for platform builders), CubeSandbox
|
||||
(self-hosted multi-tenant microVM service), endo-familiar
|
||||
(capability-security paradigm, no OS isolation).
|
||||
|
||||
The microVM cluster (matchlock, smolmachines, boxlite, microsandbox) is
|
||||
the most relevant for the v2 isolation discussion in
|
||||
The microVM cluster (matchlock, smolmachines, boxlite, microsandbox,
|
||||
CubeSandbox) is the most relevant for the v2 isolation discussion in
|
||||
[`stronger-isolation-alternatives.md`](stronger-isolation-alternatives.md):
|
||||
libkrun and Apple's Virtualization.framework have made local microVMs
|
||||
ergonomic enough that a `"runtime": "microvm"` option on a bottle is now
|
||||
plausible without a heavy stack.
|
||||
ergonomic enough that microVMs are **now bot-bottle's default backend**
|
||||
(Firecracker on KVM Linux, Apple Container on macOS), with Docker kept
|
||||
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
|
||||
|
||||
@@ -155,67 +177,105 @@ plausible without a heavy stack.
|
||||
also supported.
|
||||
- **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
|
||||
|
||||
| Axis | bot-bottle | endo-familiar | litterbox | agent-safehouse | matchlock | tilde.run | boxlite | microsandbox | smolmachines |
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
| 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 |
|
||||
| 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) |
|
||||
| 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 |
|
||||
| Config | JSON manifest (bottles + agents) | Programmatic refs | CLI wizard | Profile files / shell fns | CLI / SDK | DSL + CLI + SDK | SDK | CLI / SDK / MCP | TOML Smolfile |
|
||||
| Maturity | Active May 2026 | Research (2022+) | Early (~66 ⭐) | Active (~1.4k ⭐) | Experimental (~574 ⭐) | Private preview | YC, ~4.7k ⭐ | YC, ~6k ⭐, beta | ~3.1k ⭐ |
|
||||
| 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 ⭐ |
|
||||
|
||||
## What's closest, what's different
|
||||
|
||||
**Closest in design and scope.** agent-safehouse and litterbox sit
|
||||
nearest bot-bottle: local, single-user, thin wrappers over an
|
||||
existing OS primitive, low-dep. The split is the isolation primitive —
|
||||
bot-bottle uses Docker + pipelock egress (plus gVisor where
|
||||
available); agent-safehouse uses `sandbox-exec`; litterbox uses Podman +
|
||||
Landlock. matchlock and smolmachines are spiritually close on the
|
||||
*policy* side (default-deny net, per-host allowlist) but use microVMs
|
||||
instead of containers.
|
||||
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.
|
||||
|
||||
**Solving a different problem.** tilde.run is hosted SaaS for team /
|
||||
production agent pipelines with data-versioned rollback — explicitly
|
||||
opposite to bot-bottle's "infrastructure I control" goal. boxlite and
|
||||
microsandbox are infrastructure libraries aimed at platform builders
|
||||
embedding sandboxes into agent frameworks; they would be a *backend*
|
||||
bot-bottle could call, not a competitor to its manifest layer.
|
||||
endo-familiar is in a different paradigm entirely: capability passing
|
||||
rather than kernel boundaries.
|
||||
opposite to bot-bottle's "infrastructure I control" goal. boxlite,
|
||||
microsandbox, and CubeSandbox are infrastructure libraries/services aimed
|
||||
at platform builders embedding sandboxes into agent frameworks; they
|
||||
would be a *backend* bot-bottle could call, not a competitor to its
|
||||
manifest layer. endo-familiar is in a different paradigm entirely:
|
||||
capability passing rather than kernel boundaries.
|
||||
|
||||
## Borrowable ideas
|
||||
|
||||
What bot-bottle already has that the survey suggested as
|
||||
differentiators:
|
||||
- Default-deny egress with a per-agent allowlist (pipelock).
|
||||
- Default-deny egress with a per-agent allowlist (own egress scanner).
|
||||
- DLP scanning of outbound traffic.
|
||||
- Bottle / agent split (manifest layer above the isolation primitive).
|
||||
- gVisor auto-detection on Linux.
|
||||
|
||||
Ideas worth considering, without abandoning the Python-stdlib-first / local-Docker
|
||||
stance:
|
||||
Ideas worth considering, without abandoning the Python-stdlib-first /
|
||||
local, single-operator stance:
|
||||
|
||||
1. **Per-use SSH key confirmation** (from litterbox). Even with
|
||||
KnownHostKey pinning and pipelock egress, a wrapper SSH agent that
|
||||
KnownHostKey pinning and the egress DLP scanner, a wrapper SSH agent that
|
||||
prompts on each key use (e.g. via `osascript` / `notify-send`) would
|
||||
catch an agent doing something off-policy with a key it legitimately
|
||||
holds. Pure-stdlib, no new deps.
|
||||
2. **In-flight secret injection** (from matchlock). Pipelock already
|
||||
does egress allowlisting and DLP; teaching it to *inject* tokens at
|
||||
2. **In-flight secret injection** (from matchlock). The egress scanner
|
||||
already does allowlisting and DLP; teaching it to *inject* tokens at
|
||||
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.
|
||||
Fits the existing pipelock architecture.
|
||||
3. **MicroVM backend as an opt-in bottle type** — already on the radar
|
||||
in `stronger-isolation-alternatives.md`. microsandbox, smolmachines,
|
||||
and matchlock all show that libkrun + Apple's
|
||||
Virtualization.framework is ergonomic enough that a
|
||||
`"runtime": "microvm"` field on a bottle is plausible without a heavy
|
||||
stack.
|
||||
Fits the existing egress-proxy architecture.
|
||||
3. **MicroVM backend** — ~~on the radar~~ **shipped since this survey.**
|
||||
microVMs are now bot-bottle's default (Firecracker on KVM Linux, Apple
|
||||
Container on macOS); Docker is the legacy fallback. The libkrun / Apple
|
||||
Virtualization.framework ergonomics that microsandbox, smolmachines,
|
||||
and matchlock demonstrated turned out to be enough to make it the
|
||||
default rather than an opt-in.
|
||||
|
||||
Not worth borrowing: the SDK-first programmatic API style of boxlite /
|
||||
microsandbox (cuts against the declarative-manifest stance), and the
|
||||
@@ -230,3 +290,122 @@ hosted-SaaS dashboard model of tilde.run (cuts against the
|
||||
- The `superradcompany/microsandbox` URL in the original prompt
|
||||
redirects to `microsandbox/microsandbox`; the surveyed project is the
|
||||
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.
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
# 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.
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user