Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c7ab3e0957 | |||
| 034f774529 | |||
| 5b359fe8d2 | |||
| 015ff52eda | |||
| 4302678f3e | |||
| 3a6fbad057 | |||
| a800a417d9 | |||
| 293218035d | |||
| 727eafe0f9 | |||
| 1ec114b6d7 | |||
| aa44feea02 | |||
| f2e2572a40 | |||
| 7069fa225d | |||
| aa224c4381 |
@@ -24,8 +24,19 @@ jobs:
|
|||||||
|
|
||||||
- name: Run pylint
|
- name: Run pylint
|
||||||
run: |
|
run: |
|
||||||
# Run pylint on all Python files in the repo
|
# Pylint's normal exit code is nonzero for any emitted finding,
|
||||||
find . -name '*.py' -not -path './.venv/*' -not -path './.git/*' | xargs pylint --fail-under=8.0
|
# regardless of --fail-under. Preserve the full report but enforce
|
||||||
|
# the aggregate score this workflow promises.
|
||||||
|
set +e
|
||||||
|
find . -name '*.py' -not -path './.venv/*' -not -path './.git/*' \
|
||||||
|
| xargs pylint --fail-under=8.0 \
|
||||||
|
| tee /tmp/pylint-output.txt
|
||||||
|
set -e
|
||||||
|
SCORE=$(sed -n \
|
||||||
|
's/^Your code has been rated at \([-0-9.]*\)\/10.*/\1/p' \
|
||||||
|
/tmp/pylint-output.txt | tail -1)
|
||||||
|
test -n "$SCORE"
|
||||||
|
awk -v score="$SCORE" 'BEGIN { exit !(score >= 8.0) }'
|
||||||
|
|
||||||
- name: Run pyright
|
- name: Run pyright
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
name: tracker-policy-issues
|
||||||
|
|
||||||
|
on:
|
||||||
|
issues:
|
||||||
|
types: [opened, unlabeled]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
label-issue:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
issues: write
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Ensure the issue has a label
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: python3 scripts/tracker_policy.py label-issue
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
name: tracker-policy-pr
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types: [opened, edited, reopened, synchronized, labeled, unlabeled]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check-pr:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
issues: read
|
||||||
|
pull-requests: read
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Require an unlabeled PR linked to an issue
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: python3 scripts/tracker_policy.py check-pr
|
||||||
@@ -171,6 +171,15 @@ When an outbound DLP detector matches a token, the route's `dlp.outbound_on_matc
|
|||||||
|
|
||||||
More examples in `examples/`. Full design lives under `docs/prds/`; the trust-boundary rationale is in `docs/prds/0011-per-file-md-manifest.md`.
|
More examples in `examples/`. Full design lives under `docs/prds/`; the trust-boundary rationale is in `docs/prds/0011-per-file-md-manifest.md`.
|
||||||
|
|
||||||
|
## Tracker policy
|
||||||
|
|
||||||
|
Issues are the canonical work items and own all tracker labels; every issue
|
||||||
|
must have at least one. Pull requests stay unlabeled and deliberately reference
|
||||||
|
an issue with `Closes #…`, `Part of #…`, or another form defined in
|
||||||
|
[`ADR 0005`](docs/decisions/0005-issues-own-tracker-metadata.md). Gitea Actions
|
||||||
|
enforces the convention for new work from 2026-07-18 onward. Earlier closed
|
||||||
|
PRs are grandfathered rather than given artificial retrospective issues.
|
||||||
|
|
||||||
## Trademarks
|
## Trademarks
|
||||||
|
|
||||||
bot-bottle is an independent project and is not affiliated with, endorsed by, or sponsored by Anthropic, PBC. "Claude" and "Claude Code" are trademarks of Anthropic, PBC; the project name uses "claude" descriptively to indicate that the tool runs Claude Code inside a sandbox.
|
bot-bottle is an independent project and is not affiliated with, endorsed by, or sponsored by Anthropic, PBC. "Claude" and "Claude Code" are trademarks of Anthropic, PBC; the project name uses "claude" descriptively to indicate that the tool runs Claude Code inside a sandbox.
|
||||||
|
|||||||
@@ -38,6 +38,13 @@ COMMANDS = {
|
|||||||
"supervise": cmd_supervise,
|
"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:
|
def usage() -> None:
|
||||||
sys.stderr.write(f"usage: {PROG} <command> [args...]\n\n")
|
sys.stderr.write(f"usage: {PROG} <command> [args...]\n\n")
|
||||||
@@ -80,7 +87,7 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
usage()
|
usage()
|
||||||
die(f"unknown command: {command}")
|
die(f"unknown command: {command}")
|
||||||
mgr = StoreManager.instance()
|
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("bot-bottle: database schema is out of date\n")
|
||||||
sys.stderr.write("Migrate now? [y/N] ")
|
sys.stderr.write("Migrate now? [y/N] ")
|
||||||
sys.stderr.flush()
|
sys.stderr.flush()
|
||||||
|
|||||||
@@ -419,18 +419,24 @@ PY
|
|||||||
while IFS=' ' read -r old new ref; do
|
while IFS=' ' read -r old new ref; do
|
||||||
[ -z "$ref" ] && continue
|
[ -z "$ref" ] && continue
|
||||||
[ "$new" = "$zero" ] && continue
|
[ "$new" = "$zero" ] && continue
|
||||||
if [ "$old" = "$zero" ]; then
|
# Scan only the commits this push introduces — those reachable from
|
||||||
# New ref: scan only the commits this push introduces — those
|
# $new but not from any ref the gate already has. Everything already
|
||||||
# reachable from $new but not from any ref the gate already has.
|
# on the gate arrived via upstream mirror-fetch or a previously
|
||||||
# Everything already on the gate arrived via upstream mirror-fetch
|
# gitleaks-scanned push, so it's already-upstream or already-scanned;
|
||||||
# or a previously gitleaks-scanned push, so it's already-upstream
|
# re-scanning it only resurfaces historical fixture findings.
|
||||||
# or already-scanned; re-scanning it (the old `$new` full-ancestry
|
#
|
||||||
# range) only resurfaces historical findings and blocks every new
|
# Applies to both new refs and updates. The old existing-branch range
|
||||||
# branch. See PRD 0028 / issue #106.
|
# `$old..$new` walks commits reachable from the new tip but not the
|
||||||
log_opts="$new --not --all"
|
# *old branch tip*: on a rebase/force-push onto a freshly-advanced
|
||||||
else
|
# main that pulls in all of main's new history (incl. the deliberate
|
||||||
log_opts="$old..$new"
|
# sandbox-escape gitleaks fixtures), blocking the push. `--not --all`
|
||||||
fi
|
# 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
|
echo "git-gate: gitleaks scanning $ref ($log_opts)" >&2
|
||||||
if ! gitleaks git --log-opts="$log_opts" --no-banner --redact 1>&2; then
|
if ! gitleaks git --log-opts="$log_opts" --no-banner --redact 1>&2; then
|
||||||
echo "git-gate: gitleaks rejected push to $ref" >&2
|
echo "git-gate: gitleaks rejected push to $ref" >&2
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ from .supervise_types import (
|
|||||||
STATUS_MODIFIED,
|
STATUS_MODIFIED,
|
||||||
STATUS_REJECTED,
|
STATUS_REJECTED,
|
||||||
TOOLS,
|
TOOLS,
|
||||||
|
TOOL_CHECK_PROPOSAL,
|
||||||
TOOL_EGRESS_ALLOW,
|
TOOL_EGRESS_ALLOW,
|
||||||
TOOL_EGRESS_BLOCK,
|
TOOL_EGRESS_BLOCK,
|
||||||
TOOL_EGRESS_TOKEN_ALLOW,
|
TOOL_EGRESS_TOKEN_ALLOW,
|
||||||
@@ -263,6 +264,7 @@ __all__ = [
|
|||||||
"TOOLS",
|
"TOOLS",
|
||||||
"EGRESS_FORWARD_PROXY",
|
"EGRESS_FORWARD_PROXY",
|
||||||
"EGRESS_INTROSPECT_URL",
|
"EGRESS_INTROSPECT_URL",
|
||||||
|
"TOOL_CHECK_PROPOSAL",
|
||||||
"TOOL_EGRESS_ALLOW",
|
"TOOL_EGRESS_ALLOW",
|
||||||
"TOOL_EGRESS_BLOCK",
|
"TOOL_EGRESS_BLOCK",
|
||||||
"TOOL_GITLEAKS_ALLOW",
|
"TOOL_GITLEAKS_ALLOW",
|
||||||
|
|||||||
@@ -2,14 +2,24 @@
|
|||||||
|
|
||||||
Per-bottle MCP server exposing tools the agent calls to propose egress
|
Per-bottle MCP server exposing tools the agent calls to propose egress
|
||||||
config changes when stuck. The tools are `egress-allow`,
|
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.
|
1. Validates the proposed file syntactically.
|
||||||
2. Writes a Proposal to the host SQLite database.
|
2. Writes a Proposal to the host SQLite database.
|
||||||
3. Blocks polling for a matching Response row.
|
3. Blocks polling for a matching Response row, up to a short grace
|
||||||
4. Returns the operator's `{status, notes}` to the agent.
|
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
|
One shared server fronts every bottle (PRD 0070) and attributes each
|
||||||
proposal to the calling bottle by source IP, resolved from the orchestrator
|
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.
|
* `initialize` — handshake; returns server info + caps.
|
||||||
* `notifications/initialized` — ack-only.
|
* `notifications/initialized` — ack-only.
|
||||||
* `tools/list` — returns the tool definitions.
|
* `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).
|
Everything else returns JSON-RPC error -32601 (method not found).
|
||||||
|
|
||||||
@@ -232,6 +244,31 @@ TOOL_DEFINITIONS: list[dict[str, object]] = [
|
|||||||
),
|
),
|
||||||
"inputSchema": _proposal_input_schema(),
|
"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,
|
deadline=deadline,
|
||||||
)
|
)
|
||||||
except TimeoutError:
|
except TimeoutError:
|
||||||
text = format_pending_response_text(config.response_timeout_seconds)
|
text = format_pending_response_text(proposal.id, config.response_timeout_seconds)
|
||||||
return {
|
return {
|
||||||
"content": [{"type": "text", "text": text}],
|
"content": [{"type": "text", "text": text}],
|
||||||
"isError": False,
|
"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:
|
def format_response_text(response: "_sv.Response") -> str:
|
||||||
"""Pretty-print a Response for the tool's text content. The agent
|
"""Pretty-print a Response for the tool's text content. The agent
|
||||||
reads the text and decides whether to retry / give up / surface."""
|
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)
|
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([
|
return "\n".join([
|
||||||
"status: pending",
|
"status: pending",
|
||||||
|
f"proposal_id: {proposal_id}",
|
||||||
(
|
(
|
||||||
"notes: operator response timed out after "
|
f"notes: no operator decision within {timeout_seconds:g}s; the "
|
||||||
f"{timeout_seconds:g}s; proposal remains queued"
|
"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.
|
# — silently dropping base routes like api.anthropic.com on approval.
|
||||||
if req.params.get("name") == _sv.TOOL_LIST_EGRESS_ROUTES:
|
if req.params.get("name") == _sv.TOOL_LIST_EGRESS_ROUTES:
|
||||||
return self._resolved_routes_payload()
|
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
|
# Attribute the proposal to the source-IP-resolved bottle, so the one
|
||||||
# shared server queues each bottle's proposal under its own slug.
|
# shared server queues each bottle's proposal under its own slug.
|
||||||
return handle_tools_call(req.params, self._attributed_config(config))
|
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_GITLEAKS_ALLOW = "gitleaks-allow"
|
||||||
TOOL_EGRESS_TOKEN_ALLOW = "egress-token-allow"
|
TOOL_EGRESS_TOKEN_ALLOW = "egress-token-allow"
|
||||||
TOOL_LIST_EGRESS_ROUTES = "list-egress-routes"
|
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, ...] = (
|
TOOLS: tuple[str, ...] = (
|
||||||
TOOL_EGRESS_ALLOW,
|
TOOL_EGRESS_ALLOW,
|
||||||
TOOL_EGRESS_BLOCK,
|
TOOL_EGRESS_BLOCK,
|
||||||
@@ -156,6 +160,7 @@ __all__ = [
|
|||||||
"TOOLS",
|
"TOOLS",
|
||||||
"TOOL_EGRESS_ALLOW",
|
"TOOL_EGRESS_ALLOW",
|
||||||
"TOOL_EGRESS_BLOCK",
|
"TOOL_EGRESS_BLOCK",
|
||||||
|
"TOOL_CHECK_PROPOSAL",
|
||||||
"TOOL_EGRESS_TOKEN_ALLOW",
|
"TOOL_EGRESS_TOKEN_ALLOW",
|
||||||
"TOOL_GITLEAKS_ALLOW",
|
"TOOL_GITLEAKS_ALLOW",
|
||||||
"TOOL_LIST_EGRESS_ROUTES",
|
"TOOL_LIST_EGRESS_ROUTES",
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# ADR 0005: Keep tracker metadata on issues
|
||||||
|
|
||||||
|
- **Status:** Accepted
|
||||||
|
- **Date:** 2026-07-18
|
||||||
|
- **Deciders:** didericis
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Gitea exposes labels on both issues and pull requests. Applying the same labels
|
||||||
|
to both copies planning metadata, creates a synchronization obligation, and
|
||||||
|
makes disagreements between the two records possible. At the same time,
|
||||||
|
unlabelled objects look accidental unless the repository states which object
|
||||||
|
owns the metadata.
|
||||||
|
|
||||||
|
The repository already uses issues as work items and PRs as implementations of
|
||||||
|
those work items. At this decision's cutoff, all open PRs reference issues, but
|
||||||
|
121 of 219 historically merged PRs do not. Manufacturing retrospective issues
|
||||||
|
for that history would create records that never participated in planning and
|
||||||
|
would make the issue history less truthful.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Issues are the canonical tracker records and own labels. Every issue has at
|
||||||
|
least one label. An issue opened or left without labels receives
|
||||||
|
`Status/Needs Triage` automatically until it is classified.
|
||||||
|
|
||||||
|
Pull requests carry no labels. Every new PR deliberately references at least
|
||||||
|
one existing issue in its title or description with one of these forms:
|
||||||
|
|
||||||
|
- `Closes #123`, `Fixes #123`, or `Resolves #123` when merging completes it.
|
||||||
|
- `Part of #123`, `Related to #123`, `Refs #123`, or `References #123` when it
|
||||||
|
contributes without completing it.
|
||||||
|
|
||||||
|
Gitea Actions enforces both PR rules as a status check and repairs the empty
|
||||||
|
issue-label state. Branch protection makes the PR policy check required.
|
||||||
|
|
||||||
|
The policy applies from 2026-07-18 onward. Existing issues may be labelled as
|
||||||
|
they are encountered, but closed PRs are grandfathered: no retrospective
|
||||||
|
issues or PR labels are created solely to make history conform.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Classification, priority, and workflow metadata have one source of truth.
|
||||||
|
- A PR's issue link is the navigation path to its planning metadata.
|
||||||
|
- Multi-PR issues do not require copied or synchronized labels.
|
||||||
|
- `Status/Needs Triage` is an intentional fallback, not a final
|
||||||
|
classification.
|
||||||
|
- Direct issue creation remains convenient; automation repairs a missing label
|
||||||
|
immediately after creation because Gitea has no native required-label rule.
|
||||||
|
- The required check must be configured in branch protection after this
|
||||||
|
workflow lands.
|
||||||
|
|
||||||
|
## Links
|
||||||
|
|
||||||
|
- Issue #405.
|
||||||
|
- `.gitea/workflows/tracker-policy.yml`.
|
||||||
|
- `scripts/tracker_policy.py`.
|
||||||
@@ -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,61 @@ general AI-agent sandbox / containment projects — some Claude-specific,
|
|||||||
some agent-agnostic, some hosted SaaS — and contrasts them with
|
some agent-agnostic, some hosted SaaS — and contrasts them with
|
||||||
bot-bottle's design.
|
bot-bottle's design.
|
||||||
|
|
||||||
Research conducted 2026-05-11.
|
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.
|
||||||
|
|
||||||
|
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
|
## Summary
|
||||||
|
|
||||||
Eight projects surveyed. None duplicate bot-bottle's combination of
|
Fifteen projects surveyed across two categories: isolation/sandbox tools
|
||||||
local Docker, declarative JSON manifest, per-agent egress allowlist via
|
and governance/pre-action authorization layers (the latter don't provide
|
||||||
pipelock, and bottle/agent split. Two clusters stand out:
|
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,
|
- **Closest neighbours** — agent-safehouse and litterbox: local,
|
||||||
single-user, thin wrappers over an existing OS primitive
|
single-user, thin wrappers over an existing OS primitive
|
||||||
(`sandbox-exec`, Podman + Landlock).
|
(`sandbox-exec`, Podman + Landlock).
|
||||||
- **Different category** — tilde.run (hosted SaaS), boxlite and
|
- **Different category (isolation)** — tilde.run (hosted SaaS), boxlite
|
||||||
microsandbox (microVM libraries for platform builders), endo-familiar
|
and microsandbox (microVM libraries for platform builders), CubeSandbox
|
||||||
|
(self-hosted multi-tenant microVM service), endo-familiar
|
||||||
(capability-security paradigm, no OS isolation).
|
(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) is
|
The microVM cluster (matchlock, smolmachines, boxlite, microsandbox,
|
||||||
the most relevant for the v2 isolation discussion in
|
CubeSandbox) is the most relevant for the v2 isolation discussion in
|
||||||
[`stronger-isolation-alternatives.md`](stronger-isolation-alternatives.md):
|
[`stronger-isolation-alternatives.md`](stronger-isolation-alternatives.md):
|
||||||
libkrun and Apple's Virtualization.framework have made local microVMs
|
libkrun and Apple's Virtualization.framework have made local microVMs
|
||||||
ergonomic enough that a `"runtime": "microvm"` option on a bottle is now
|
ergonomic enough that microVMs are **now bot-bottle's default backend**
|
||||||
plausible without a heavy stack.
|
(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
|
## Per-project notes
|
||||||
|
|
||||||
@@ -155,67 +189,272 @@ plausible without a heavy stack.
|
|||||||
also supported.
|
also supported.
|
||||||
- **Maturity**: Active through April 2026.
|
- **Maturity**: Active through April 2026.
|
||||||
|
|
||||||
|
### CubeSandbox *(added 2026-07-18)*
|
||||||
|
- **Source**: https://github.com/TencentCloud/CubeSandbox ;
|
||||||
|
HN launch https://news.ycombinator.com/item?id=47863430
|
||||||
|
- **License**: Apache 2.0 (~10.4k stars). By Tencent Cloud; described as
|
||||||
|
"battle-tested, production-ready" infra already running in Tencent
|
||||||
|
Cloud. Rust / Go / C.
|
||||||
|
- **Isolation**: MicroVMs via RustVMM + KVM — "each sandbox gets its own
|
||||||
|
Guest OS kernel, no Docker shared-kernel escapes." Hardware-level
|
||||||
|
isolation, dedicated kernel per instance.
|
||||||
|
- **Locality**: Self-hosted, but **server/cluster-oriented**, not a
|
||||||
|
single-user local CLI. Deploy guides target PVM cloud VMs, bare metal,
|
||||||
|
and dev. A single 96-vCPU host is claimed to run 2,000+ concurrent
|
||||||
|
sandboxes.
|
||||||
|
- **Agent integration**: **Drop-in E2B SDK replacement** (single env-var
|
||||||
|
change) — the headline compatibility claim. OpenClaw assistant
|
||||||
|
integration; general LLM-code execution. Aimed at platform builders,
|
||||||
|
not one developer's laptop.
|
||||||
|
- **Config**: Programmatic via the E2B-compatible SDK. No declarative
|
||||||
|
manifest.
|
||||||
|
- **Network policy**: This is the striking part — **domain allowlists,
|
||||||
|
instant block on unauthorized egress, full audit logs, per-sandbox
|
||||||
|
traffic tokens, policy-routing egress**, enforced by an eBPF-based
|
||||||
|
virtual switch giving kernel-level network isolation. Closest match yet
|
||||||
|
to bot-bottle's own default-deny + per-bottle allowlist egress model.
|
||||||
|
- **Credentials**: **Credential vault** — agents call external APIs / LLMs
|
||||||
|
while "keys never enter the sandbox, model context, or logs." Same
|
||||||
|
in-flight-injection idea as matchlock, but productized as a vault.
|
||||||
|
- **Performance**: <60ms cold start (claimed 2.5–50× faster than
|
||||||
|
alternatives), <5MB memory per instance; millisecond snapshot rollback
|
||||||
|
is upcoming.
|
||||||
|
- **Maturity**: Open-sourced July 2026 off production Tencent Cloud use;
|
||||||
|
most-starred project in this set (~10.4k).
|
||||||
|
|
||||||
|
### 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 (0–3, 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 (0–1,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
|
## Comparison table
|
||||||
|
|
||||||
| Axis | bot-bottle | endo-familiar | litterbox | agent-safehouse | matchlock | tilde.run | boxlite | microsandbox | smolmachines |
|
*Isolation/sandbox tools only. AGT and OAP are governance layers — see their per-project notes above.*
|
||||||
|---|---|---|---|---|---|---|---|---|---|
|
|
||||||
| 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) |
|
| Axis | bot-bottle | endo-familiar | litterbox | agent-safehouse | matchlock | tilde.run | boxlite | microsandbox | smolmachines | CubeSandbox | Cleanroom | container-use | Docker sbx | Anthropic srt |
|
||||||
| 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 |
|
| 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 |
|
||||||
| Agent target | Claude Code | Generic (demo) | Generic | Multi-agent wrapper | Generic (+ Claude/OpenAI SDKs) | Claude focus | Generic | Claude + Cursor (MCP/Skills) | Generic (AGENTS.md) |
|
| 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 |
|
||||||
| 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 |
|
| 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) |
|
||||||
| Parallel agents | Yes (one bottle per agent) | n/a | Not addressed | One at a time | Multiple VMs | Yes (dashboard) | SDK-level | SDK-level | Architectural |
|
| 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) |
|
||||||
| Config | JSON manifest (bottles + agents) | Programmatic refs | CLI wizard | Profile files / shell fns | CLI / SDK | DSL + CLI + SDK | SDK | CLI / SDK / MCP | TOML Smolfile |
|
| 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 |
|
||||||
| Maturity | Active May 2026 | Research (2022+) | Early (~66 ⭐) | Active (~1.4k ⭐) | Experimental (~574 ⭐) | Private preview | YC, ~4.7k ⭐ | YC, ~6k ⭐, beta | ~3.1k ⭐ |
|
| 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
|
## What's closest, what's different
|
||||||
|
|
||||||
**Closest in design and scope.** agent-safehouse and litterbox sit
|
**Closest in design and scope.** agent-safehouse and litterbox sit
|
||||||
nearest bot-bottle: local, single-user, thin wrappers over an
|
nearest bot-bottle: local, single-user, thin wrappers over an
|
||||||
existing OS primitive, low-dep. The split is the isolation primitive —
|
existing OS primitive, low-dep. The split is the isolation primitive —
|
||||||
bot-bottle uses Docker + pipelock egress (plus gVisor where
|
bot-bottle now defaults to a VM per bottle (Firecracker microVM on KVM
|
||||||
available); agent-safehouse uses `sandbox-exec`; litterbox uses Podman +
|
Linux, Apple Container on macOS) with its own DLP-scanning egress proxy,
|
||||||
Landlock. matchlock and smolmachines are spiritually close on the
|
keeping Docker only as a legacy fallback; agent-safehouse uses
|
||||||
*policy* side (default-deny net, per-host allowlist) but use microVMs
|
`sandbox-exec`; litterbox uses Podman + Landlock. matchlock and
|
||||||
instead of containers.
|
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 /
|
**Solving a different problem.** tilde.run is hosted SaaS for team /
|
||||||
production agent pipelines with data-versioned rollback — explicitly
|
production agent pipelines with data-versioned rollback — explicitly
|
||||||
opposite to bot-bottle's "infrastructure I control" goal. boxlite and
|
opposite to bot-bottle's "infrastructure I control" goal. boxlite,
|
||||||
microsandbox are infrastructure libraries aimed at platform builders
|
microsandbox, and CubeSandbox are infrastructure libraries/services aimed
|
||||||
embedding sandboxes into agent frameworks; they would be a *backend*
|
at platform builders embedding sandboxes into agent frameworks; they
|
||||||
bot-bottle could call, not a competitor to its manifest layer.
|
would be a *backend* bot-bottle could call, not a competitor to its
|
||||||
endo-familiar is in a different paradigm entirely: capability passing
|
manifest layer. endo-familiar is in a different paradigm entirely:
|
||||||
rather than kernel boundaries.
|
capability passing rather than kernel boundaries.
|
||||||
|
|
||||||
## Borrowable ideas
|
## Borrowable ideas
|
||||||
|
|
||||||
What bot-bottle already has that the survey suggested as
|
What bot-bottle already has that the survey suggested as
|
||||||
differentiators:
|
differentiators:
|
||||||
- Default-deny egress with a per-agent allowlist (pipelock).
|
- Default-deny egress with a per-agent allowlist (own egress scanner).
|
||||||
- DLP scanning of outbound traffic.
|
- DLP scanning of outbound traffic.
|
||||||
- Bottle / agent split (manifest layer above the isolation primitive).
|
- Bottle / agent split (manifest layer above the isolation primitive).
|
||||||
- gVisor auto-detection on Linux.
|
- gVisor auto-detection on Linux.
|
||||||
|
|
||||||
Ideas worth considering, without abandoning the Python-stdlib-first / local-Docker
|
Ideas worth considering, without abandoning the Python-stdlib-first /
|
||||||
stance:
|
local, single-operator stance:
|
||||||
|
|
||||||
1. **Per-use SSH key confirmation** (from litterbox). Even with
|
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
|
prompts on each key use (e.g. via `osascript` / `notify-send`) would
|
||||||
catch an agent doing something off-policy with a key it legitimately
|
catch an agent doing something off-policy with a key it legitimately
|
||||||
holds. Pure-stdlib, no new deps.
|
holds. Pure-stdlib, no new deps.
|
||||||
2. **In-flight secret injection** (from matchlock). Pipelock already
|
2. **In-flight secret injection** (from matchlock). The egress scanner
|
||||||
does egress allowlisting and DLP; teaching it to *inject* tokens at
|
already does allowlisting and DLP; teaching it to *inject* tokens at
|
||||||
proxy time so e.g. `GITEA_TOKEN` never appears in the container's
|
proxy time so e.g. `GITEA_TOKEN` never appears in the container's
|
||||||
env would close the "agent reads its own env and exfiltrates" path.
|
env would close the "agent reads its own env and exfiltrates" path.
|
||||||
Fits the existing pipelock architecture.
|
Fits the existing egress-proxy architecture.
|
||||||
3. **MicroVM backend as an opt-in bottle type** — already on the radar
|
3. **MicroVM backend** — ~~on the radar~~ **shipped since this survey.**
|
||||||
in `stronger-isolation-alternatives.md`. microsandbox, smolmachines,
|
microVMs are now bot-bottle's default (Firecracker on KVM Linux, Apple
|
||||||
and matchlock all show that libkrun + Apple's
|
Container on macOS); Docker is the legacy fallback. The libkrun / Apple
|
||||||
Virtualization.framework is ergonomic enough that a
|
Virtualization.framework ergonomics that microsandbox, smolmachines,
|
||||||
`"runtime": "microvm"` field on a bottle is plausible without a heavy
|
and matchlock demonstrated turned out to be enough to make it the
|
||||||
stack.
|
default rather than an opt-in.
|
||||||
|
|
||||||
Not worth borrowing: the SDK-first programmatic API style of boxlite /
|
Not worth borrowing: the SDK-first programmatic API style of boxlite /
|
||||||
microsandbox (cuts against the declarative-manifest stance), and the
|
microsandbox (cuts against the declarative-manifest stance), and the
|
||||||
@@ -230,3 +469,176 @@ hosted-SaaS dashboard model of tilde.run (cuts against the
|
|||||||
- The `superradcompany/microsandbox` URL in the original prompt
|
- The `superradcompany/microsandbox` URL in the original prompt
|
||||||
redirects to `microsandbox/microsandbox`; the surveyed project is the
|
redirects to `microsandbox/microsandbox`; the surveyed project is the
|
||||||
same.
|
same.
|
||||||
|
- CubeSandbox performance/scale numbers (<60ms cold start, <5MB/instance,
|
||||||
|
2,000+ sandboxes per 96-vCPU host) are the project's own launch claims,
|
||||||
|
not independently verified here.
|
||||||
|
|
||||||
|
## Addendum 2026-07-18 — CubeSandbox and the positioning read
|
||||||
|
|
||||||
|
CubeSandbox (Tencent Cloud, Apache 2.0, ~10.4k stars, HN launch
|
||||||
|
[#47863430](https://news.ycombinator.com/item?id=47863430)) is the first
|
||||||
|
project in this survey to combine, in one open-source stack, everything
|
||||||
|
bot-bottle treated as its differentiator:
|
||||||
|
|
||||||
|
- **Egress custody (connection level)** — default-deny domain allowlist
|
||||||
|
(L7 domain/SNI filtering), instant block on unauthorized egress,
|
||||||
|
per-sandbox traffic tokens, full audit logs of destinations (eBPF
|
||||||
|
virtual switch, "CubeVS"). This matches bot-bottle's egress scanner at
|
||||||
|
the *connection level*, productized — see the one thing it does **not**
|
||||||
|
match, below.
|
||||||
|
- **Credential custody** — a vault where keys "never enter the sandbox,
|
||||||
|
model context, or logs." This is the in-flight-injection idea from
|
||||||
|
matchlock, but as a first-class feature, and it's exactly the
|
||||||
|
cross-vendor "egress audit + custody" wedge the monetization
|
||||||
|
positioning treats as the one defensible moat.
|
||||||
|
- **Isolation on par with bot-bottle's current default** — a dedicated
|
||||||
|
guest kernel per sandbox (RustVMM/KVM). bot-bottle now defaults to the
|
||||||
|
same class of boundary (Firecracker microVM / Apple Container), so this
|
||||||
|
is parity, not an edge; CubeSandbox's remaining edge is running that
|
||||||
|
per-kernel isolation multi-tenant at scale on one host.
|
||||||
|
|
||||||
|
The one axis CubeSandbox does **not** cover — and where bot-bottle stays
|
||||||
|
distinctive:
|
||||||
|
|
||||||
|
- **Content DLP on *authorized* channels.** CubeSandbox's egress control
|
||||||
|
is connection-level: it decides *whether* a destination is allowed and
|
||||||
|
logs it, and its vault keeps *injected* credentials out of the sandbox
|
||||||
|
entirely. Neither inspects the *payload* of traffic to an allowed
|
||||||
|
destination. So an agent that exfiltrates over a permitted channel —
|
||||||
|
pasting a repo's contents, an agent-derived secret, or PHI into an
|
||||||
|
allowed API/domain — is not caught by CubeSandbox. bot-bottle's own
|
||||||
|
egress DLP scanner does scan that: response + websocket content against
|
||||||
|
the resolved per-flow config, with per-bottle token redaction (see
|
||||||
|
recent egress commits). The vault
|
||||||
|
approach is arguably *stronger* for the specific case of pre-known
|
||||||
|
injected credentials (they can't leak if they were never present), but
|
||||||
|
it is not a substitute for content inspection of everything else.
|
||||||
|
|
||||||
|
**Long-running posture — a sharper axis than raw isolation.** E2B and
|
||||||
|
CubeSandbox are *ephemeral-per-task* by design; a long-running agent is an
|
||||||
|
architected pattern on top, not the default. E2B: 5-minute default
|
||||||
|
timeout, continuous runtime tier-capped (~1h Hobby / ~24h Pro), duration
|
||||||
|
achieved via **pause/resume** (preserves filesystem + memory + processes;
|
||||||
|
reconnect by sandbox ID via `Sandbox.connect()`; resume resets the timeout
|
||||||
|
to 5 min; auto-pause via `on_timeout: "pause"`). CubeSandbox mirrors this
|
||||||
|
(E2B drop-in) with first-class auto pause/resume and hundred-ms
|
||||||
|
checkpoint/fork — and, self-hosted, sets its own timeout policy with no
|
||||||
|
vendor tier caps. bot-bottle inverts the model: a bottle is **persistent,
|
||||||
|
named, and supervised by default** — long-running *is* the default, not a
|
||||||
|
session-management loop over pause/resume. smolmachines is the other
|
||||||
|
persistent-by-default project in this set. For anyone building agents that
|
||||||
|
run for hours/days, this posture difference matters more than the
|
||||||
|
isolation primitive.
|
||||||
|
|
||||||
|
**DX — the "run Claude yolo-style" bar.** The reason `claude
|
||||||
|
--dangerously-skip-permissions` is so widely used is DX: it's one command
|
||||||
|
and the agent just goes. The bottle thesis is to make a *sandboxed* run
|
||||||
|
that easy — `start <agent>` builds the image on first run and drops you
|
||||||
|
into an interactive Claude session that already has
|
||||||
|
`--dangerously-skip-permissions` on by default
|
||||||
|
(`contrib/claude/agent_provider.py`), with the sandbox as the guardrail
|
||||||
|
instead of per-action prompts. On this axis the field splits cleanly:
|
||||||
|
- **Wrappers around the agent** (as-easy-as-native): bot-bottle and
|
||||||
|
**agent-safehouse** (`safehouse claude --dangerously-skip-permissions`).
|
||||||
|
These *are* the run-Claude experience. agent-safehouse is the real DX
|
||||||
|
peer — but it's macOS-only Seatbelt, single-run, and doesn't address
|
||||||
|
network egress; bot-bottle adds VM-grade isolation, egress DLP, and
|
||||||
|
persistent/parallel bottles across macOS + Linux.
|
||||||
|
- **Libraries / services** (you build the run yourself): boxlite,
|
||||||
|
microsandbox, CubeSandbox, E2B. These hand you an SDK or a cluster and
|
||||||
|
expect you to wire the agent in — powerful for platform builders,
|
||||||
|
heavyweight for "just run Claude on my laptop." microsandbox's MCP/Skills
|
||||||
|
angle is *sandbox-as-a-tool the agent calls*, which is the inverse of
|
||||||
|
wrapping the agent.
|
||||||
|
- **In between:** litterbox (wizard + build, Linux only), smolmachines
|
||||||
|
(SSH into a named machine), matchlock (run a command in a VM).
|
||||||
|
|
||||||
|
So DX is a genuine bot-bottle differentiator, and the only project that
|
||||||
|
matches it (agent-safehouse) does so with materially weaker isolation and
|
||||||
|
no egress story. "As easy as native yolo, but actually sandboxed" is a
|
||||||
|
defensible one-liner.
|
||||||
|
|
||||||
|
Why it still doesn't collide head-on:
|
||||||
|
|
||||||
|
1. **Shape.** CubeSandbox is a *multi-tenant service for platform
|
||||||
|
builders* (drop-in E2B replacement, SDK-driven, 2,000 sandboxes on a
|
||||||
|
box). bot-bottle is a *single-operator, declarative-manifest tool for
|
||||||
|
the infrastructure I run*. Different buyer, different ergonomics — no
|
||||||
|
JSON manifest, no bottle/agent split, no "one command on my laptop."
|
||||||
|
2. **Backend, not competitor.** Like boxlite/microsandbox, CubeSandbox is
|
||||||
|
something bot-bottle could sit *on top of* — a `"runtime": "microvm"`
|
||||||
|
or `"runtime": "cubesandbox"` backend under the manifest layer — while
|
||||||
|
keeping the manifest, the bottle/agent split, and the local,
|
||||||
|
single-operator default.
|
||||||
|
|
||||||
|
Why it matters anyway:
|
||||||
|
|
||||||
|
- The "nobody else bundles connection-level egress allowlist + audit +
|
||||||
|
in-flight credential custody" line is **no longer true for the
|
||||||
|
primitive** — a well-funded, 10k-star open-source project now ships it.
|
||||||
|
But **content DLP on authorized channels is still not matched** (see
|
||||||
|
above), and neither is the *layer above* the primitive (declarative
|
||||||
|
manifest, cross-vendor orchestration, operator UX, the
|
||||||
|
phone-control/dashboard north star). Those two — outbound-payload DLP
|
||||||
|
and the orchestration layer — are where the defensible ground now sits;
|
||||||
|
the connection-level allowlist + vault mechanism, on its own, is no
|
||||||
|
longer differentiating. Revisit the monetization open/paid line with
|
||||||
|
that in mind.
|
||||||
|
- Worth a closer look at **how** CubeSandbox does credential injection
|
||||||
|
and per-sandbox egress tokens (eBPF virtual switch vs. bot-bottle's
|
||||||
|
mitmproxy egress proxy) before the next iteration of bot-bottle's
|
||||||
|
in-flight-secret feature — see borrowable idea #2 above.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|||||||
@@ -0,0 +1,345 @@
|
|||||||
|
# HN discourse on agent sandbox safety — June/July 2026
|
||||||
|
|
||||||
|
A survey of community opinion and notable security disclosures on Hacker
|
||||||
|
News and adjacent sources over June–July 2026. The question: what does
|
||||||
|
the current discourse say about whether sandboxes are sufficient for
|
||||||
|
agentic AI safety, and where does bot-bottle land against the issues
|
||||||
|
being raised?
|
||||||
|
|
||||||
|
Research conducted 2026-07-18.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
The past month marks a turning point in community opinion. Earlier in
|
||||||
|
2026, the debate was mostly "which sandbox tool is best?" By June–July,
|
||||||
|
a cascade of critical CVEs and novel attack classes has shifted the
|
||||||
|
framing to "sandboxes are not enough — what else do you need?" The
|
||||||
|
attacks that drove this shift are structurally distinct: most route
|
||||||
|
through legitimate, trusted channels (Sentry issues, MCP descriptions,
|
||||||
|
README files) rather than exploiting the isolation boundary directly.
|
||||||
|
|
||||||
|
bot-bottle's architecture holds up well against the direct-escape class
|
||||||
|
(Firecracker/Apple Container default backends, credentials never in the
|
||||||
|
agent's env, harness entirely on the host). The remaining gap is prompt
|
||||||
|
injection — attacker-controlled data interpreted as model instructions.
|
||||||
|
Egress controls and prompt injection defenses are orthogonal: egress
|
||||||
|
limits what the agent can *send out*; injection is about what it is
|
||||||
|
*told to do*. The two don't substitute for each other. Inside a tightly-
|
||||||
|
egressed sandbox a successful injection can't exfiltrate to unknown
|
||||||
|
hosts, but it can still corrupt the work product, push malicious commits
|
||||||
|
past a secret scanner, or use allowlisted channels for exfiltration.
|
||||||
|
Those residual risks are addressed below.
|
||||||
|
|
||||||
|
## The sandboxing boom sets the stage
|
||||||
|
|
||||||
|
The preceding months generated a wave of sandbox tooling. A March 28
|
||||||
|
Ask HN thread
|
||||||
|
([#47444917](https://news.ycombinator.com/item?id=47444917)) catalogued
|
||||||
|
the explosion: E2B, AIO Sandbox, AgentSphere, Yolobox, Exe.dev,
|
||||||
|
AgentFence, DenoSandbox, Capsule (WASM), ERA, Vibekit, Daytona, Modal,
|
||||||
|
Nono, and more — all launched within roughly 12 months. A parallel March
|
||||||
|
9 thread ([#47185250](https://news.ycombinator.com/item?id=47185250))
|
||||||
|
surveyed what developers were actually deploying: "containers or YOLO"
|
||||||
|
dominated. The honest community mood was that most teams hadn't solved
|
||||||
|
this and were shipping anyway.
|
||||||
|
|
||||||
|
## The June–July attack cascade
|
||||||
|
|
||||||
|
Six attack patterns broke in quick succession. Together they form the
|
||||||
|
argument that the community's framing was wrong: the threat model for
|
||||||
|
agents isn't just "code that escapes its container" — it's also prompt
|
||||||
|
injection, where attacker-controlled data is interpreted as model
|
||||||
|
instructions regardless of whether any isolation boundary was crossed.
|
||||||
|
Sections 2–4 below are all the same attack class; the "trusted channel"
|
||||||
|
label describes the delivery vector, not a different threat.
|
||||||
|
|
||||||
|
### 1. Sandbox escape CVEs (DuneSlide, CVE-2026-39861)
|
||||||
|
|
||||||
|
Cato AI Labs disclosed **DuneSlide** (CVE-2026-50548/50549, CVSS 9.8),
|
||||||
|
a pair of flaws in Cursor 2.x. CVE-2026-50548 abuses the sandbox's
|
||||||
|
`working_directory` parameter to point writes at system files; CVE-26-50549
|
||||||
|
exploits a symlink-resolution fallback that fails open. Both start with
|
||||||
|
a prompt injection and end in sandbox escape — and Cato's framing was
|
||||||
|
blunt: "each CVE defeats a different guardrail; the problem is
|
||||||
|
structural, not a string of one-offs."
|
||||||
|
|
||||||
|
Claude Code's own sandbox had a similar escape this year:
|
||||||
|
**CVE-2026-39861** (symlink flaw). The CurXecute/MCPoison/CVE-2026-26268
|
||||||
|
chain from Cursor added a poisoned Slack message, a swap-after-approval
|
||||||
|
MCP config, and a Git hook as three more entry points in the same
|
||||||
|
attack class.
|
||||||
|
|
||||||
|
All patched, but the pattern holds: any application-level sandbox that
|
||||||
|
takes attacker-influenced values as path parameters is reachable from a
|
||||||
|
prompt injection.
|
||||||
|
|
||||||
|
### 2. Prompt injection via MCP data (Agentjacking)
|
||||||
|
|
||||||
|
Tenet's "Agentjacking" technique planted a fake bug report in Sentry's
|
||||||
|
MCP output. When an agent queries Sentry to fix open issues, the
|
||||||
|
malicious event is rendered as structured content visually
|
||||||
|
indistinguishable from a real Sentry event, and the agent executes the
|
||||||
|
embedded instructions with the developer's full privileges. Hit rate
|
||||||
|
across Claude Code and Cursor: **85%**. The route is entirely through a
|
||||||
|
legitimately-authorized MCP channel — no isolation boundary is crossed;
|
||||||
|
the injection arrives inbound through a channel the sandbox explicitly
|
||||||
|
trusts.
|
||||||
|
|
||||||
|
The Cloud Security Alliance's summary: treat observability, bug-report,
|
||||||
|
and integration data as **untrusted agent input**, not neutral
|
||||||
|
development metadata.
|
||||||
|
|
||||||
|
### 3. README-embedded prompt injection
|
||||||
|
|
||||||
|
A July disclosure showed malicious instructions hidden in `README.md`
|
||||||
|
— a file that receives no trust prompt and requires no elevated access.
|
||||||
|
When asked point-blank whether the repo held hidden instructions, both
|
||||||
|
Claude Sonnet 4.6 and GPT-5.5 said no. A payload written for Sonnet
|
||||||
|
4.6 transferred unchanged to Sonnet 5, Opus 4.8, and GPT-5.5. The
|
||||||
|
attack surface is every repo an agent is asked to work in.
|
||||||
|
|
||||||
|
### 4. Prompt injection via MCP tool descriptions
|
||||||
|
|
||||||
|
Microsoft research (June 30) showed that attacker-controlled MCP tool
|
||||||
|
description fields can silently redirect agent behavior. The injection
|
||||||
|
is embedded in metadata the model reads during tool selection — before
|
||||||
|
any sandbox enforcement or egress check runs, and entirely on the
|
||||||
|
inbound path that egress controls cannot touch.
|
||||||
|
|
||||||
|
### 5. MCP STDIO command injection (10 CVEs)
|
||||||
|
|
||||||
|
OX Security disclosed a systemic command injection class in Anthropic's
|
||||||
|
MCP protocol, covering 10 CVEs across multiple coding agents. The
|
||||||
|
Windsurf case (CVE-2026-30615): processing attacker-controlled HTML
|
||||||
|
causes the agent to auto-register a malicious MCP STDIO server and
|
||||||
|
execute arbitrary commands with no further user interaction.
|
||||||
|
|
||||||
|
### 6. LiteLLM gateway compromise (CVE-2026-40217, CVE-2026-42271)
|
||||||
|
|
||||||
|
CVE-2026-40217 exposes LiteLLM's guardrail sandbox via `exec()` with no
|
||||||
|
source filtering. CVE-2026-42271 (exploited in the wild, added to CISA's
|
||||||
|
KEV catalog) lets callers spawn subprocesses through MCP preview
|
||||||
|
endpoints. The threat extends to any agent routed through a compromised
|
||||||
|
LiteLLM proxy: the proxy can swap model responses for forged tool calls
|
||||||
|
in transit, giving the attacker a reverse shell from the developer's
|
||||||
|
machine.
|
||||||
|
|
||||||
|
## HN community opinion clusters
|
||||||
|
|
||||||
|
**"Move enforcement to the kernel, not the app"** — the Nono Show HN
|
||||||
|
([#46849615](https://news.ycombinator.com/item?id=46849615)) and a
|
||||||
|
kernel-sandbox thread
|
||||||
|
([#47066574](https://news.ycombinator.com/item?id=47066574)) both argued
|
||||||
|
that application-layer sandboxes are inherently bypassable by the code
|
||||||
|
they're sandboxing. The academic framing, from *Red-Teaming the Agentic
|
||||||
|
Red-Team* ([arXiv 2606.24496](https://arxiv.org/pdf/2606.24496)):
|
||||||
|
"enforcement should occur at the OS level via the kernel refusing system
|
||||||
|
calls that violate policy at runtime — not pre-execution argument
|
||||||
|
validation in tool calls."
|
||||||
|
|
||||||
|
**"The harness belongs outside the sandbox"** — a May thread
|
||||||
|
([#47990675](https://news.ycombinator.com/item?id=47990675)) converged
|
||||||
|
on clean architectural separation: harness in one VM, tool execution in
|
||||||
|
another. Top comment: "having the harness in one VM, and tool use applied
|
||||||
|
to user data in another, is about as safe as you can be at present."
|
||||||
|
Several replies described a hypervisor-like policy layer — sitting outside
|
||||||
|
both VMs — as the right long-term model.
|
||||||
|
|
||||||
|
**"Sandboxes are too coarse-grained"** — a Feb thread
|
||||||
|
([#47006445](https://news.ycombinator.com/item?id=47006445)) argued
|
||||||
|
that VMs don't answer the real question: knowing whether an agent
|
||||||
|
*should* be sending an email or making a transaction. "Everything's just
|
||||||
|
in the same big box." This framing picked up traction through June–July
|
||||||
|
as the trusted-channel attacks dominated.
|
||||||
|
|
||||||
|
**"MCP's trust model is the real problem"** — the month's recurring
|
||||||
|
theme. MCP by design gives agents access to authorized external services.
|
||||||
|
Once a trusted channel delivers a malicious payload, filesystem sandboxing
|
||||||
|
is irrelevant. The community call: treat all MCP tool metadata and return
|
||||||
|
values as untrusted input subject to policy validation before ingestion,
|
||||||
|
and disable automatic MCP server loading from untrusted repositories.
|
||||||
|
|
||||||
|
## How bot-bottle addresses these issues
|
||||||
|
|
||||||
|
### What it covers well
|
||||||
|
|
||||||
|
**Direct sandbox escape (CVEs, container breakout)**
|
||||||
|
|
||||||
|
bot-bottle's default backends are Firecracker microVM (KVM Linux) and
|
||||||
|
Apple Container (macOS). Both run the agent in a separate VM with a
|
||||||
|
dedicated kernel — the container-escape CVE class (Dirty Pipe, runc
|
||||||
|
escapes, DuneSlide's path-parameter abuse) requires escaping a real
|
||||||
|
hypervisor boundary, not just a namespace. On the legacy Docker backend,
|
||||||
|
gVisor auto-detection provides a userspace syscall barrier for hosts where
|
||||||
|
neither KVM nor Apple Container is available.
|
||||||
|
|
||||||
|
The bot-bottle process itself runs entirely on the host, outside the VM.
|
||||||
|
This is the "harness outside the sandbox" architecture the HN thread
|
||||||
|
converged on as best practice. The bottle manifest, egress rules, and
|
||||||
|
secrets never enter the agent VM.
|
||||||
|
|
||||||
|
**Credential theft on sandbox escape**
|
||||||
|
|
||||||
|
Even on a successful VM/container escape, the agent has nothing useful
|
||||||
|
to steal. Credentials are injected in-flight by the gateway proxy
|
||||||
|
(`auth.scheme` / `auth.token_ref` in the egress route config) — `printenv`
|
||||||
|
inside the agent shows proxy URLs only. The git-gate similarly holds the
|
||||||
|
upstream SSH credential on the host; the agent pushes through a
|
||||||
|
gitleaks-scanned daemon that forwards clean refs upstream. An escaped
|
||||||
|
agent gets the host filesystem, not the keys.
|
||||||
|
|
||||||
|
**Orphaned-agent credential risk**
|
||||||
|
|
||||||
|
bot-bottle is explicitly ephemeral: when the agent exits, `cli.py` tears
|
||||||
|
down every gateway and both networks — nothing persists between runs. The
|
||||||
|
agent never holds credentials, so there is nothing to orphan.
|
||||||
|
|
||||||
|
**MCP config redirection / STDIO auto-registration**
|
||||||
|
|
||||||
|
The trust boundary at `$HOME` means bottles live only under
|
||||||
|
`~/.bot-bottle/bottles/` — a cloned repo cannot add egress routes or
|
||||||
|
redirect env vars to attacker hosts (the design rationale is in
|
||||||
|
`docs/prds/0011-per-file-md-manifest.md`). Auto-registering a malicious
|
||||||
|
MCP STDIO server from within the agent is still sandboxed by the VM, and
|
||||||
|
any outbound calls from that server must pass the egress allowlist and
|
||||||
|
outbound DLP scanner.
|
||||||
|
|
||||||
|
**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
|
||||||
|
description poisoning — the final step in most attacks is exfiltration.
|
||||||
|
bot-bottle's egress allowlist is default-deny with a per-bottle host
|
||||||
|
allowlist; unknown hosts get a hard 403. Outbound DLP scanning
|
||||||
|
(`outbound_detectors: [token_patterns, known_secrets]`) catches tokens
|
||||||
|
and secrets in outbound bodies; the `supervise` policy (default for
|
||||||
|
manifest routes) holds the request for operator approval rather than
|
||||||
|
silently blocking it. Together these limit what a successful injection
|
||||||
|
can *do* even if it succeeds at the model layer.
|
||||||
|
|
||||||
|
**LiteLLM / compromised-proxy attacks**
|
||||||
|
|
||||||
|
bot-bottle does not use LiteLLM. The model API route (e.g.
|
||||||
|
`api.anthropic.com`) is an auto-injected provider route on the egress
|
||||||
|
allowlist; the agent dials the gateway, not the model API directly.
|
||||||
|
A compromised third-party proxy is not in the architecture.
|
||||||
|
|
||||||
|
### Where it is weaker
|
||||||
|
|
||||||
|
**Prompt injection**
|
||||||
|
|
||||||
|
Egress controls and prompt injection defenses are orthogonal. Egress
|
||||||
|
limits what the agent can *send out* (outbound leg); prompt injection
|
||||||
|
is about what attacker-controlled data *tells the agent to do* (inbound
|
||||||
|
leg). The two don't substitute for each other and must be treated
|
||||||
|
separately.
|
||||||
|
|
||||||
|
The inbound DLP scanner (`inbound_detectors: [naive_injection_detection]`)
|
||||||
|
is the only runtime defense against injection arriving through allowlisted
|
||||||
|
channels — Sentry MCP responses, MCP tool descriptions, README content.
|
||||||
|
It is explicitly pattern-matching and will not catch a sufficiently
|
||||||
|
crafted payload. There is no semantic / intent-level gate between what
|
||||||
|
the model decides and what the agent executes.
|
||||||
|
|
||||||
|
**Blast radius within the permitted scope**
|
||||||
|
|
||||||
|
Inside a tightly-egressed sandbox a successful injection can't
|
||||||
|
exfiltrate to unknown hosts, but it still has real options:
|
||||||
|
|
||||||
|
- *Work product corruption.* The agent can modify, delete, or backdoor
|
||||||
|
files in the working directory. This is within its permitted scope;
|
||||||
|
egress controls have nothing to say about it.
|
||||||
|
|
||||||
|
- *Malicious commits past the git-gate.* The git-gate scans outbound
|
||||||
|
refs for secrets (gitleaks), not for semantic code intent. A prompt-
|
||||||
|
injected agent can commit subtly malicious code — logic bombs,
|
||||||
|
backdoored auth paths, code that exfiltrates data through the
|
||||||
|
application's own HTTP clients at runtime — that looks clean to a
|
||||||
|
secret scanner.
|
||||||
|
|
||||||
|
- *Exfiltration through allowlisted channels.* If an attacker knows or
|
||||||
|
can predict what hosts are in the egress allowlist, those channels are
|
||||||
|
available for exfiltration. A GitHub remote being allowlisted means
|
||||||
|
"push to an attacker-controlled fork" is viable. A logging endpoint
|
||||||
|
being allowlisted means structured data can leave through it. The
|
||||||
|
outbound DLP scanner catches credential tokens and known secrets but
|
||||||
|
not arbitrary business data.
|
||||||
|
|
||||||
|
- *Dependency installation within the sandbox.* An agent that runs
|
||||||
|
`npm install` or `pip install` on attacker-specified packages executes
|
||||||
|
code inside the sandbox with the same capabilities the agent has:
|
||||||
|
filesystem access, tool calls, calls to allowlisted hosts. Supply chain
|
||||||
|
injection via package names is in the same injection family, triggered
|
||||||
|
by the same prompt-injection path.
|
||||||
|
|
||||||
|
### What would close the remaining gaps
|
||||||
|
|
||||||
|
The blast-radius risks above point at two distinct mitigations that
|
||||||
|
don't yet exist in bot-bottle:
|
||||||
|
|
||||||
|
- *Outbound intent classification.* The egress addon today scans
|
||||||
|
outbound request content for token patterns. What it lacks is
|
||||||
|
awareness of context — it can't distinguish "agent is pushing a
|
||||||
|
legitimate commit" from "agent was injected and is pushing a backdoor."
|
||||||
|
The `supervise` policy is already the right shape for human-in-the-loop
|
||||||
|
review on sensitive outbound actions; extending it with context from
|
||||||
|
the agent's recent tool calls (what files were touched, what was the
|
||||||
|
triggering task) would narrow the gap.
|
||||||
|
|
||||||
|
- *Semantic code review on git push.* gitleaks is the wrong tool for
|
||||||
|
catching injected logic. A review step on outbound commits — even a
|
||||||
|
simple diff summary surfaced in `cli.py supervise` before the push is
|
||||||
|
forwarded — would close the malicious-commit path without requiring
|
||||||
|
the agent to be fully trusted.
|
||||||
|
|
||||||
|
## Sources
|
||||||
|
|
||||||
|
- [Ask HN: The new wave of AI agent sandboxes? (Mar 2026)](https://news.ycombinator.com/item?id=47444917)
|
||||||
|
- [OK, let's survey how everybody is sandboxing AI coding agents (Mar 2026)](https://news.ycombinator.com/item?id=47185250)
|
||||||
|
- [The agent harness belongs outside the sandbox (May 2026)](https://news.ycombinator.com/item?id=47990675)
|
||||||
|
- [Show HN: Nono – Kernel-enforced sandboxing for AI agents (Feb 2026)](https://news.ycombinator.com/item?id=46849615)
|
||||||
|
- [Kernel-enforced sandbox for AI agents, MCP and LLM workloads (Feb 2026)](https://news.ycombinator.com/item?id=47066574)
|
||||||
|
- [Sandboxes will be left in 2026 (Feb 2026)](https://news.ycombinator.com/item?id=47006445)
|
||||||
|
- [Critical Cursor Flaws / DuneSlide – The Hacker News](https://thehackernews.com/2026/07/critical-cursor-flaws-could-let-prompt.html)
|
||||||
|
- [Agentjacking Attack – The Hacker News](https://thehackernews.com/2026/06/agentjacking-attack-tricks-ai-coding.html)
|
||||||
|
- [Friendly Fire: AI Agents Built to Catch Malicious Code – The Hacker News](https://thehackernews.com/2026/07/friendly-fire-ai-agents-built-to-catch.html)
|
||||||
|
- [Microsoft Warns Poisoned MCP Tool Descriptions – The Hacker News](https://thehackernews.com/2026/06/microsoft-warns-poisoned-mcp-tool.html)
|
||||||
|
- [MCP STDIO Command Injection Advisory – OX Security](https://www.ox.security/blog/mcp-supply-chain-advisory-rce-vulnerabilities-across-the-ai-ecosystem/)
|
||||||
|
- [LiteLLM Vulnerability Chain – The Hacker News](https://thehackernews.com/2026/06/litellm-vulnerability-chain-lets-low.html)
|
||||||
|
- [Red-Teaming the Agentic Red-Team (arXiv 2606.24496)](https://arxiv.org/pdf/2606.24496)
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Enforce the repository's issue/PR metadata policy in Gitea Actions."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
ISSUE_REFERENCE = re.compile(
|
||||||
|
r"(?im)\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?|part\s+of|"
|
||||||
|
r"related\s+to|refs?|references)\s+#(\d+)\b"
|
||||||
|
)
|
||||||
|
TRIAGE_LABEL = "Status/Needs Triage"
|
||||||
|
|
||||||
|
|
||||||
|
def deliberate_issue_numbers(title: str, body: str) -> set[int]:
|
||||||
|
"""Return same-repository issue numbers referenced intentionally."""
|
||||||
|
return {int(match) for match in ISSUE_REFERENCE.findall(f"{title}\n{body}")}
|
||||||
|
|
||||||
|
|
||||||
|
class GiteaApi:
|
||||||
|
"""Small API client using the Actions-provided repository token."""
|
||||||
|
|
||||||
|
def __init__(self, api_url: str, repository: str, token: str) -> None:
|
||||||
|
self.base = f"{api_url.rstrip('/')}/repos/{repository}"
|
||||||
|
self.token = token
|
||||||
|
|
||||||
|
def request(self, method: str, path: str, payload: object | None = None) -> Any:
|
||||||
|
data = None if payload is None else json.dumps(payload).encode()
|
||||||
|
request = urllib.request.Request(
|
||||||
|
f"{self.base}{path}",
|
||||||
|
data=data,
|
||||||
|
method=method,
|
||||||
|
headers={
|
||||||
|
"Authorization": f"token {self.token}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(request, timeout=15) as response:
|
||||||
|
if response.status == 204:
|
||||||
|
return None
|
||||||
|
return json.load(response)
|
||||||
|
|
||||||
|
|
||||||
|
def check_pull_request(event: dict[str, Any], api: GiteaApi) -> list[str]:
|
||||||
|
"""Return policy violations for a pull_request event."""
|
||||||
|
pull = event["pull_request"]
|
||||||
|
errors: list[str] = []
|
||||||
|
labels = pull.get("labels") or []
|
||||||
|
if labels:
|
||||||
|
errors.append(
|
||||||
|
"PRs must be unlabeled; put tracker metadata on the linked issue "
|
||||||
|
f"(found: {', '.join(label['name'] for label in labels)})."
|
||||||
|
)
|
||||||
|
|
||||||
|
numbers = deliberate_issue_numbers(pull.get("title", ""), pull.get("body", ""))
|
||||||
|
if not numbers:
|
||||||
|
errors.append(
|
||||||
|
"PR must reference an issue with Closes/Fixes/Resolves #N, "
|
||||||
|
"Part of #N, Related to #N, Refs #N, or References #N."
|
||||||
|
)
|
||||||
|
return errors
|
||||||
|
|
||||||
|
real_issues = 0
|
||||||
|
for number in sorted(numbers):
|
||||||
|
try:
|
||||||
|
item = api.request("GET", f"/issues/{number}")
|
||||||
|
except urllib.error.HTTPError as error:
|
||||||
|
if error.code == 404:
|
||||||
|
errors.append(f"Referenced issue #{number} does not exist.")
|
||||||
|
continue
|
||||||
|
raise
|
||||||
|
if item.get("pull_request") is not None:
|
||||||
|
errors.append(f"#{number} is a pull request, not an issue.")
|
||||||
|
else:
|
||||||
|
real_issues += 1
|
||||||
|
|
||||||
|
if not real_issues and not errors:
|
||||||
|
errors.append("PR must reference at least one real issue.")
|
||||||
|
return errors
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_issue_label(event: dict[str, Any], api: GiteaApi) -> bool:
|
||||||
|
"""Apply the triage label if an issue event leaves the issue unlabeled."""
|
||||||
|
issue = event["issue"]
|
||||||
|
if issue.get("pull_request") is not None or issue.get("labels"):
|
||||||
|
return False
|
||||||
|
labels = api.request("GET", "/labels?limit=100")
|
||||||
|
triage = next((label for label in labels if label["name"] == TRIAGE_LABEL), None)
|
||||||
|
if triage is None:
|
||||||
|
raise RuntimeError(f"repository label {TRIAGE_LABEL!r} does not exist")
|
||||||
|
api.request("POST", f"/issues/{issue['number']}/labels", {"labels": [triage["id"]]})
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _load_event(path: str) -> dict[str, Any]:
|
||||||
|
return json.loads(Path(path).read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("command", choices=("check-pr", "label-issue"))
|
||||||
|
parser.add_argument("--event", default=os.environ.get("GITHUB_EVENT_PATH"))
|
||||||
|
args = parser.parse_args()
|
||||||
|
if not args.event:
|
||||||
|
parser.error("--event or GITHUB_EVENT_PATH is required")
|
||||||
|
|
||||||
|
api = GiteaApi(
|
||||||
|
os.environ["GITHUB_API_URL"],
|
||||||
|
os.environ["GITHUB_REPOSITORY"],
|
||||||
|
os.environ["GITHUB_TOKEN"],
|
||||||
|
)
|
||||||
|
event = _load_event(args.event)
|
||||||
|
if args.command == "check-pr":
|
||||||
|
errors = check_pull_request(event, api)
|
||||||
|
if errors:
|
||||||
|
print("\n".join(f"::error::{error}" for error in errors))
|
||||||
|
return 1
|
||||||
|
print("PR tracker policy passed.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
changed = ensure_issue_label(event, api)
|
||||||
|
print(f"Applied {TRIAGE_LABEL}." if changed else "Issue already has a label.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
"""Resolved paths to system binaries used by subprocess-based tests.
|
||||||
|
|
||||||
|
NixOS and other non-FHS hosts don't populate ``/bin`` (there is no
|
||||||
|
``/bin/sleep``), so tests that spawn real short-lived helper processes
|
||||||
|
must resolve the binary from ``PATH`` rather than hardcoding an FHS path.
|
||||||
|
Import the resolved constant (e.g. ``SLEEP``) instead of writing
|
||||||
|
``/bin/sleep`` inline.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
|
||||||
|
def resolve(name: str, fallback: str) -> str:
|
||||||
|
"""Absolute path to ``name`` from ``PATH``; ``fallback`` on FHS hosts
|
||||||
|
where the binary isn't on ``PATH`` but lives at a known ``/bin`` path."""
|
||||||
|
return shutil.which(name) or fallback
|
||||||
|
|
||||||
|
|
||||||
|
# Real ``sleep`` binary. ``/bin/sleep`` is absent on NixOS; resolve from
|
||||||
|
# PATH so subprocess tests run instead of erroring with FileNotFoundError.
|
||||||
|
SLEEP = resolve("sleep", "/bin/sleep")
|
||||||
@@ -95,5 +95,60 @@ class TestMainDispatch(unittest.TestCase):
|
|||||||
self.assertEqual(130, main(["x"]))
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
Tests both the helper functions in `bot_bottle.gateway_init`
|
Tests both the helper functions in `bot_bottle.gateway_init`
|
||||||
and the supervisor's end-to-end signal / exit-code behavior. The
|
and the supervisor's end-to-end signal / exit-code behavior. The
|
||||||
end-to-end tests use real subprocesses (`/bin/sleep`,
|
end-to-end tests use real subprocesses (`sleep`, `/bin/sh -c '...'`) —
|
||||||
`/bin/sh -c '...'`) — short-lived, no docker required — so they
|
short-lived, no docker required — so they run under `tests/unit/`
|
||||||
run under `tests/unit/` rather than `tests/integration/`."""
|
rather than `tests/integration/`."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -25,6 +25,7 @@ from bot_bottle.gateway_init import (
|
|||||||
_env_for_daemon,
|
_env_for_daemon,
|
||||||
_selected_daemons,
|
_selected_daemons,
|
||||||
)
|
)
|
||||||
|
from tests._bin import SLEEP
|
||||||
|
|
||||||
|
|
||||||
class TestEnvForDaemon(unittest.TestCase):
|
class TestEnvForDaemon(unittest.TestCase):
|
||||||
@@ -182,7 +183,7 @@ class TestSupervisor(unittest.TestCase):
|
|||||||
# up and the supervisor never set shutdown_at.
|
# up and the supervisor never set shutdown_at.
|
||||||
specs = [
|
specs = [
|
||||||
_DaemonSpec("crasher", ("/bin/sh", "-c", "exit 1")),
|
_DaemonSpec("crasher", ("/bin/sh", "-c", "exit 1")),
|
||||||
_DaemonSpec("longrun", ("/bin/sleep", "30")),
|
_DaemonSpec("longrun", (SLEEP, "30")),
|
||||||
]
|
]
|
||||||
sup = _Supervisor(specs)
|
sup = _Supervisor(specs)
|
||||||
sup.start_all()
|
sup.start_all()
|
||||||
@@ -214,7 +215,7 @@ class TestSupervisor(unittest.TestCase):
|
|||||||
# signal-killed longrun's negative returncode.
|
# signal-killed longrun's negative returncode.
|
||||||
specs = [
|
specs = [
|
||||||
_DaemonSpec("crasher", ("/bin/sh", "-c", "exit 1")),
|
_DaemonSpec("crasher", ("/bin/sh", "-c", "exit 1")),
|
||||||
_DaemonSpec("longrun", ("/bin/sleep", "30")),
|
_DaemonSpec("longrun", (SLEEP, "30")),
|
||||||
]
|
]
|
||||||
sup = _Supervisor(specs)
|
sup = _Supervisor(specs)
|
||||||
sup.start_all()
|
sup.start_all()
|
||||||
@@ -259,7 +260,7 @@ class TestSupervisor(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
specs = [
|
specs = [
|
||||||
_DaemonSpec("egress", sighup_marker),
|
_DaemonSpec("egress", sighup_marker),
|
||||||
_DaemonSpec("other", ("/bin/sleep", "30")),
|
_DaemonSpec("other", (SLEEP, "30")),
|
||||||
]
|
]
|
||||||
sup = _Supervisor(specs)
|
sup = _Supervisor(specs)
|
||||||
sup.start_all()
|
sup.start_all()
|
||||||
@@ -282,7 +283,7 @@ class TestSupervisor(unittest.TestCase):
|
|||||||
self._drive(sup)
|
self._drive(sup)
|
||||||
|
|
||||||
def test_forward_signal_unknown_daemon_no_op(self):
|
def test_forward_signal_unknown_daemon_no_op(self):
|
||||||
specs = [_DaemonSpec("a", ("/bin/sleep", "30"))]
|
specs = [_DaemonSpec("a", (SLEEP, "30"))]
|
||||||
sup = _Supervisor(specs)
|
sup = _Supervisor(specs)
|
||||||
sup.start_all()
|
sup.start_all()
|
||||||
delivered = sup.forward_signal(signal.SIGHUP, "ghost")
|
delivered = sup.forward_signal(signal.SIGHUP, "ghost")
|
||||||
@@ -294,8 +295,8 @@ class TestSupervisor(unittest.TestCase):
|
|||||||
# Restart one daemon; the other (supervise, the MCP server
|
# Restart one daemon; the other (supervise, the MCP server
|
||||||
# in production) must remain untouched.
|
# in production) must remain untouched.
|
||||||
specs = [
|
specs = [
|
||||||
_DaemonSpec("git-gate", ("/bin/sleep", "30")),
|
_DaemonSpec("git-gate", (SLEEP, "30")),
|
||||||
_DaemonSpec("supervise", ("/bin/sleep", "30")),
|
_DaemonSpec("supervise", (SLEEP, "30")),
|
||||||
]
|
]
|
||||||
sup = _Supervisor(specs)
|
sup = _Supervisor(specs)
|
||||||
sup.start_all()
|
sup.start_all()
|
||||||
@@ -319,8 +320,8 @@ class TestSupervisor(unittest.TestCase):
|
|||||||
|
|
||||||
def test_request_restart_is_drained_by_tick(self):
|
def test_request_restart_is_drained_by_tick(self):
|
||||||
specs = [
|
specs = [
|
||||||
_DaemonSpec("git-gate", ("/bin/sleep", "30")),
|
_DaemonSpec("git-gate", (SLEEP, "30")),
|
||||||
_DaemonSpec("supervise", ("/bin/sleep", "30")),
|
_DaemonSpec("supervise", (SLEEP, "30")),
|
||||||
]
|
]
|
||||||
sup = _Supervisor(specs)
|
sup = _Supervisor(specs)
|
||||||
sup.start_all()
|
sup.start_all()
|
||||||
@@ -343,7 +344,7 @@ class TestSupervisor(unittest.TestCase):
|
|||||||
self._drive(sup)
|
self._drive(sup)
|
||||||
|
|
||||||
def test_repeated_restart_requests_coalesce(self):
|
def test_repeated_restart_requests_coalesce(self):
|
||||||
specs = [_DaemonSpec("git-gate", ("/bin/sleep", "30"))]
|
specs = [_DaemonSpec("git-gate", (SLEEP, "30"))]
|
||||||
sup = _Supervisor(specs)
|
sup = _Supervisor(specs)
|
||||||
sup.start_all()
|
sup.start_all()
|
||||||
time.sleep(0.1)
|
time.sleep(0.1)
|
||||||
@@ -366,7 +367,7 @@ class TestSupervisor(unittest.TestCase):
|
|||||||
self._drive(sup)
|
self._drive(sup)
|
||||||
|
|
||||||
def test_request_restart_unknown_daemon_no_op(self):
|
def test_request_restart_unknown_daemon_no_op(self):
|
||||||
specs = [_DaemonSpec("a", ("/bin/sleep", "30"))]
|
specs = [_DaemonSpec("a", (SLEEP, "30"))]
|
||||||
sup = _Supervisor(specs)
|
sup = _Supervisor(specs)
|
||||||
sup.start_all()
|
sup.start_all()
|
||||||
ok = sup.request_restart("ghost")
|
ok = sup.request_restart("ghost")
|
||||||
@@ -376,7 +377,7 @@ class TestSupervisor(unittest.TestCase):
|
|||||||
self._drive(sup)
|
self._drive(sup)
|
||||||
|
|
||||||
def test_restart_unknown_daemon_no_op(self):
|
def test_restart_unknown_daemon_no_op(self):
|
||||||
specs = [_DaemonSpec("a", ("/bin/sleep", "30"))]
|
specs = [_DaemonSpec("a", (SLEEP, "30"))]
|
||||||
sup = _Supervisor(specs)
|
sup = _Supervisor(specs)
|
||||||
sup.start_all()
|
sup.start_all()
|
||||||
ok = sup.restart_daemon("ghost")
|
ok = sup.restart_daemon("ghost")
|
||||||
@@ -385,7 +386,7 @@ class TestSupervisor(unittest.TestCase):
|
|||||||
self._drive(sup)
|
self._drive(sup)
|
||||||
|
|
||||||
def test_restart_during_shutdown_is_no_op(self):
|
def test_restart_during_shutdown_is_no_op(self):
|
||||||
specs = [_DaemonSpec("git-gate", ("/bin/sleep", "30"))]
|
specs = [_DaemonSpec("git-gate", (SLEEP, "30"))]
|
||||||
sup = _Supervisor(specs)
|
sup = _Supervisor(specs)
|
||||||
sup.start_all()
|
sup.start_all()
|
||||||
sup.request_shutdown(reason="test")
|
sup.request_shutdown(reason="test")
|
||||||
@@ -395,7 +396,7 @@ class TestSupervisor(unittest.TestCase):
|
|||||||
self._drive(sup)
|
self._drive(sup)
|
||||||
|
|
||||||
def test_pending_restart_dropped_during_shutdown(self):
|
def test_pending_restart_dropped_during_shutdown(self):
|
||||||
specs = [_DaemonSpec("git-gate", ("/bin/sleep", "30"))]
|
specs = [_DaemonSpec("git-gate", (SLEEP, "30"))]
|
||||||
sup = _Supervisor(specs)
|
sup = _Supervisor(specs)
|
||||||
sup.start_all()
|
sup.start_all()
|
||||||
time.sleep(0.1)
|
time.sleep(0.1)
|
||||||
@@ -413,8 +414,8 @@ class TestSupervisor(unittest.TestCase):
|
|||||||
# both should receive SIGTERM and exit. Signal-only
|
# both should receive SIGTERM and exit. Signal-only
|
||||||
# shutdown clamps to a zero supervisor exit code.
|
# shutdown clamps to a zero supervisor exit code.
|
||||||
specs = [
|
specs = [
|
||||||
_DaemonSpec("a", ("/bin/sleep", "60")),
|
_DaemonSpec("a", (SLEEP, "60")),
|
||||||
_DaemonSpec("b", ("/bin/sleep", "60")),
|
_DaemonSpec("b", (SLEEP, "60")),
|
||||||
]
|
]
|
||||||
sup = _Supervisor(specs)
|
sup = _Supervisor(specs)
|
||||||
sup.start_all()
|
sup.start_all()
|
||||||
@@ -449,7 +450,7 @@ class TestSupervisor(unittest.TestCase):
|
|||||||
self.assertEqual(0, rc)
|
self.assertEqual(0, rc)
|
||||||
|
|
||||||
def test_idempotent_shutdown_requests(self):
|
def test_idempotent_shutdown_requests(self):
|
||||||
specs = [_DaemonSpec("a", ("/bin/sleep", "60"))]
|
specs = [_DaemonSpec("a", (SLEEP, "60"))]
|
||||||
sup = _Supervisor(specs)
|
sup = _Supervisor(specs)
|
||||||
sup.start_all()
|
sup.start_all()
|
||||||
time.sleep(0.1)
|
time.sleep(0.1)
|
||||||
@@ -470,7 +471,7 @@ class TestMainEndToEnd(unittest.TestCase):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def setUpClass(cls):
|
def setUpClass(cls):
|
||||||
for p in ("/bin/sh", "/bin/sleep"):
|
for p in ("/bin/sh", SLEEP):
|
||||||
if not Path(p).exists():
|
if not Path(p).exists():
|
||||||
raise unittest.SkipTest(f"missing {p}")
|
raise unittest.SkipTest(f"missing {p}")
|
||||||
|
|
||||||
@@ -485,8 +486,8 @@ class TestMainEndToEnd(unittest.TestCase):
|
|||||||
"import os, runpy, sys\n"
|
"import os, runpy, sys\n"
|
||||||
"from bot_bottle import gateway_init as si\n"
|
"from bot_bottle import gateway_init as si\n"
|
||||||
"si._DAEMONS = (\n"
|
"si._DAEMONS = (\n"
|
||||||
" si._DaemonSpec('alpha', ('/bin/sleep','30')),\n"
|
f" si._DaemonSpec('alpha', ({SLEEP!r},'30')),\n"
|
||||||
" si._DaemonSpec('beta', ('/bin/sleep','30')),\n"
|
f" si._DaemonSpec('beta', ({SLEEP!r},'30')),\n"
|
||||||
")\n"
|
")\n"
|
||||||
"sys.exit(si.main([]))\n"
|
"sys.exit(si.main([]))\n"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -168,16 +168,18 @@ class TestHookRender(unittest.TestCase):
|
|||||||
# Stdin is buffered to a tempfile so both phases can re-read.
|
# Stdin is buffered to a tempfile so both phases can re-read.
|
||||||
self.assertIn("refs_file=$(mktemp)", hook)
|
self.assertIn("refs_file=$(mktemp)", hook)
|
||||||
|
|
||||||
def test_new_ref_scan_scoped_to_incoming_commits(self):
|
def test_scan_scoped_to_incoming_commits(self):
|
||||||
# A new branch (old=all-zeros) must scan only commits new to the
|
# Every non-delete push scans only commits new to the gate, not
|
||||||
# gate, not the full ancestry — otherwise historical findings
|
# the full ancestry and not the `$old..$new` delta — otherwise
|
||||||
# block every new-branch push (PRD 0028 / issue #106).
|
# 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()
|
hook = git_gate_render_hook()
|
||||||
self.assertIn('log_opts="$new --not --all"', 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)
|
self.assertNotIn('log_opts="$new"', hook)
|
||||||
# Existing-branch delta scan is unchanged.
|
self.assertNotIn('log_opts="$old..$new"', hook)
|
||||||
self.assertIn('log_opts="$old..$new"', hook)
|
|
||||||
|
|
||||||
def test_forward_ssh_is_non_interactive_and_bounded(self):
|
def test_forward_ssh_is_non_interactive_and_bounded(self):
|
||||||
# No prompt (BatchMode) and a connect timeout, so an unreachable
|
# No prompt (BatchMode) and a connect timeout, so an unreachable
|
||||||
|
|||||||
@@ -32,7 +32,9 @@ from bot_bottle.supervise_server import (
|
|||||||
_RpcError,
|
_RpcError,
|
||||||
_RpcInternalError,
|
_RpcInternalError,
|
||||||
_response_timeout_from_env,
|
_response_timeout_from_env,
|
||||||
|
format_pending_response_text,
|
||||||
format_response_text,
|
format_response_text,
|
||||||
|
handle_check_proposal,
|
||||||
handle_initialize,
|
handle_initialize,
|
||||||
handle_tools_call,
|
handle_tools_call,
|
||||||
handle_tools_list,
|
handle_tools_list,
|
||||||
@@ -218,6 +220,7 @@ class TestHandleToolsList(unittest.TestCase):
|
|||||||
_sv.TOOL_EGRESS_ALLOW,
|
_sv.TOOL_EGRESS_ALLOW,
|
||||||
_sv.TOOL_EGRESS_BLOCK,
|
_sv.TOOL_EGRESS_BLOCK,
|
||||||
_sv.TOOL_LIST_EGRESS_ROUTES,
|
_sv.TOOL_LIST_EGRESS_ROUTES,
|
||||||
|
_sv.TOOL_CHECK_PROPOSAL,
|
||||||
]),
|
]),
|
||||||
sorted(names),
|
sorted(names),
|
||||||
)
|
)
|
||||||
@@ -484,9 +487,10 @@ class TestFormatResponseText(unittest.TestCase):
|
|||||||
|
|
||||||
class TestFormatPendingResponseText(unittest.TestCase):
|
class TestFormatPendingResponseText(unittest.TestCase):
|
||||||
def test_formats_timeout_message(self):
|
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("status: pending", text)
|
||||||
self.assertIn("12.5s", text)
|
self.assertIn("12.5s", text)
|
||||||
|
self.assertIn("proposal_id: prop-9", text)
|
||||||
|
|
||||||
|
|
||||||
# --- End-to-end HTTP sanity ------------------------------------------------
|
# --- End-to-end HTTP sanity ------------------------------------------------
|
||||||
@@ -685,5 +689,129 @@ class TestResolvedRoutesPayload(unittest.TestCase):
|
|||||||
_handler(None)._resolved_routes_payload()
|
_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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import unittest
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
from scripts.tracker_policy import (
|
||||||
|
TRIAGE_LABEL,
|
||||||
|
check_pull_request,
|
||||||
|
deliberate_issue_numbers,
|
||||||
|
ensure_issue_label,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestDeliberateIssueNumbers(unittest.TestCase):
|
||||||
|
def test_accepts_completing_and_noncompleting_forms(self):
|
||||||
|
self.assertEqual(
|
||||||
|
deliberate_issue_numbers("Fixes #12", "Part of #14; refs #15"),
|
||||||
|
{12, 14, 15},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_does_not_treat_incidental_number_as_link(self):
|
||||||
|
self.assertEqual(deliberate_issue_numbers("Audit #12", "See PR #14"), set())
|
||||||
|
|
||||||
|
|
||||||
|
class TestCheckPullRequest(unittest.TestCase):
|
||||||
|
def test_accepts_unlabelled_pr_linked_to_real_issue(self):
|
||||||
|
api = Mock()
|
||||||
|
api.request.return_value = {"number": 12, "pull_request": None}
|
||||||
|
event = {"pull_request": {"title": "Change", "body": "Part of #12", "labels": []}}
|
||||||
|
self.assertEqual(check_pull_request(event, api), [])
|
||||||
|
|
||||||
|
def test_rejects_labels_and_pr_reference(self):
|
||||||
|
api = Mock()
|
||||||
|
api.request.return_value = {"number": 12, "pull_request": {}}
|
||||||
|
event = {
|
||||||
|
"pull_request": {
|
||||||
|
"title": "Change",
|
||||||
|
"body": "Closes #12",
|
||||||
|
"labels": [{"name": "Kind/Bug"}],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
errors = check_pull_request(event, api)
|
||||||
|
self.assertEqual(len(errors), 2)
|
||||||
|
self.assertIn("unlabeled", errors[0])
|
||||||
|
self.assertIn("not an issue", errors[1])
|
||||||
|
|
||||||
|
|
||||||
|
class TestEnsureIssueLabel(unittest.TestCase):
|
||||||
|
def test_adds_triage_label_to_unlabelled_issue(self):
|
||||||
|
api = Mock()
|
||||||
|
api.request.side_effect = [[{"id": 55, "name": TRIAGE_LABEL}], None]
|
||||||
|
event = {"issue": {"number": 405, "labels": [], "pull_request": None}}
|
||||||
|
self.assertTrue(ensure_issue_label(event, api))
|
||||||
|
api.request.assert_any_call("POST", "/issues/405/labels", {"labels": [55]})
|
||||||
|
|
||||||
|
def test_leaves_labelled_issue_unchanged(self):
|
||||||
|
api = Mock()
|
||||||
|
event = {"issue": {"number": 405, "labels": [{"name": "Kind/Documentation"}]}}
|
||||||
|
self.assertFalse(ensure_issue_label(event, api))
|
||||||
|
api.request.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user