fix(supervise): reach the queue over RPC, get bot-bottle.db off the data plane
PRD 0070's rule — only the orchestrator opens bot-bottle.db; the data plane reaches state through the control-plane RPC — was not in force. Three data-plane daemons held a direct read-write handle on the shared SQLite file: the supervise MCP server, the egress DLP addon (the most attack-exposed process, TLS-bumping hostile traffic), and the git-gate pre-receive hook. An RCE in any of them could read every bottle's plaintext identity_token and forge attribution fleet-wide (issue #469). Add the agent half of the supervise flow to the control plane: POST /supervise/propose -> queue a proposal, 201 {proposal_id} POST /supervise/poll -> non-blocking decision poll, 200 {status,...} Both attribute the caller by (source_ip, identity_token) exactly like /resolve — never a caller-supplied slug — so a bottle can only ever queue or read its own proposals even if the data plane is compromised. A decided poll archives server-side, preserving the archive-after-read contract. Data plane: the supervise server, egress addon, and git-gate hook now queue/poll through PolicyResolver.propose_supervise / poll_supervise instead of opening the DB. supervise_server keeps its ~30s grace window by polling the RPC; egress keeps its safelist keyed by resolved bottle; the git-gate hook gets (source_ip, identity_token) from the CGI env. Packaging: drop the DB bind-mount and SUPERVISE_DB_PATH from the data-plane containers/VMs (docker gateway + infra, macOS infra, firecracker infra). The orchestrator remains the sole opener of the one file via BOT_BOTTLE_ROOT / host_db_path(). Update PRD 0070: the rule is now in force; remove the transitional caveat. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+2
-1
@@ -26,8 +26,9 @@
|
|||||||
# /git-gate-entrypoint.sh docker-cp'd at start time
|
# /git-gate-entrypoint.sh docker-cp'd at start time
|
||||||
# /git-gate/creds/* docker-cp'd at start time
|
# /git-gate/creds/* docker-cp'd at start time
|
||||||
# /git/* bare repos, populated at runtime
|
# /git/* bare repos, populated at runtime
|
||||||
# /run/supervise/bot-bottle.db bind-mounted at run time
|
|
||||||
# /home/mitmproxy/.mitmproxy/ mitmproxy CA dir
|
# /home/mitmproxy/.mitmproxy/ mitmproxy CA dir
|
||||||
|
# (No bot-bottle.db mount: the data plane reaches the supervise queue over the
|
||||||
|
# control-plane RPC and never opens the DB — PRD 0070 / issue #469.)
|
||||||
#
|
#
|
||||||
# Exposed ports inside the container:
|
# Exposed ports inside the container:
|
||||||
# 9099 egress (mitmproxy, agent-facing HTTPS proxy)
|
# 9099 egress (mitmproxy, agent-facing HTTPS proxy)
|
||||||
|
|||||||
@@ -493,10 +493,11 @@ BOT_BOTTLE_ROOT=/var/lib/bot-bottle python3 -m bot_bottle.orchestrator \\
|
|||||||
# Gateway data plane, multi-tenant: each request resolves source-IP ->
|
# Gateway data plane, multi-tenant: each request resolves source-IP ->
|
||||||
# policy against the local control plane. The VM backend reaches git over
|
# policy against the local control plane. The VM backend reaches git over
|
||||||
# git-http (9420), so the git:// daemon (git-gate, needs a per-bottle
|
# git-http (9420), so the git:// daemon (git-gate, needs a per-bottle
|
||||||
# entrypoint the consolidated model doesn't use) is left out.
|
# entrypoint the consolidated model doesn't use) is left out. No
|
||||||
|
# SUPERVISE_DB_PATH: the data plane reaches the supervise queue over the
|
||||||
|
# control-plane RPC and never opens bot-bottle.db (PRD 0070 / #469).
|
||||||
BOT_BOTTLE_GATEWAY_DAEMONS=egress,git-http,supervise \\
|
BOT_BOTTLE_GATEWAY_DAEMONS=egress,git-http,supervise \\
|
||||||
BOT_BOTTLE_ORCHESTRATOR_URL=http://127.0.0.1:{CONTROL_PLANE_PORT} \\
|
BOT_BOTTLE_ORCHESTRATOR_URL=http://127.0.0.1:{CONTROL_PLANE_PORT} \\
|
||||||
SUPERVISE_DB_PATH=/var/lib/bot-bottle/db/bot-bottle.db \\
|
|
||||||
python3 -m bot_bottle.gateway_init &
|
python3 -m bot_bottle.gateway_init &
|
||||||
|
|
||||||
# Reap as PID 1; children are backgrounded, so `wait` blocks.
|
# Reap as PID 1; children are backgrounded, so `wait` blocks.
|
||||||
|
|||||||
@@ -100,10 +100,12 @@ def _init_script(port: int) -> str:
|
|||||||
f"( cd {_SRC_IN_CONTAINER} && BOT_BOTTLE_ROOT={_DB_ROOT_IN_CONTAINER} "
|
f"( cd {_SRC_IN_CONTAINER} && BOT_BOTTLE_ROOT={_DB_ROOT_IN_CONTAINER} "
|
||||||
f"python3 -m bot_bottle.orchestrator --host 0.0.0.0 --port {port} "
|
f"python3 -m bot_bottle.orchestrator --host 0.0.0.0 --port {port} "
|
||||||
"--broker stub ) &\n"
|
"--broker stub ) &\n"
|
||||||
# Gateway data plane, multi-tenant against the local control plane.
|
# Gateway data plane, multi-tenant against the local control plane. No
|
||||||
|
# SUPERVISE_DB_PATH: the data plane reaches the supervise queue over the
|
||||||
|
# control-plane RPC and never opens bot-bottle.db (PRD 0070 / #469).
|
||||||
f"( cd /app && BOT_BOTTLE_GATEWAY_DAEMONS={_GATEWAY_DAEMONS} "
|
f"( cd /app && BOT_BOTTLE_GATEWAY_DAEMONS={_GATEWAY_DAEMONS} "
|
||||||
f"BOT_BOTTLE_ORCHESTRATOR_URL=http://127.0.0.1:{port} "
|
f"BOT_BOTTLE_ORCHESTRATOR_URL=http://127.0.0.1:{port} "
|
||||||
f"SUPERVISE_DB_PATH={_DB_PATH_IN_CONTAINER} python3 -m bot_bottle.gateway_init ) &\n"
|
f"python3 -m bot_bottle.gateway_init ) &\n"
|
||||||
"while : ; do wait ; done\n"
|
"while : ; do wait ; done\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+50
-28
@@ -40,8 +40,13 @@ from bot_bottle.egress_addon_core import (
|
|||||||
scan_inbound,
|
scan_inbound,
|
||||||
scan_outbound,
|
scan_outbound,
|
||||||
)
|
)
|
||||||
from bot_bottle import supervise as _sv
|
from bot_bottle.policy_resolver import PolicyResolveError, PolicyResolver
|
||||||
from bot_bottle.policy_resolver import PolicyResolver
|
from bot_bottle.supervise_types import (
|
||||||
|
STATUS_APPROVED,
|
||||||
|
STATUS_MODIFIED,
|
||||||
|
STATUSES,
|
||||||
|
TOOL_EGRESS_TOKEN_ALLOW,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
INTROSPECT_HOST = "_egress.local"
|
INTROSPECT_HOST = "_egress.local"
|
||||||
@@ -314,8 +319,15 @@ class EgressAddon:
|
|||||||
flow.request.headers.pop("Proxy-Authorization", None)
|
flow.request.headers.pop("Proxy-Authorization", None)
|
||||||
flow.request.headers.pop(IDENTITY_HEADER, None)
|
flow.request.headers.pop(IDENTITY_HEADER, None)
|
||||||
conn = flow.client_conn
|
conn = flow.client_conn
|
||||||
if not token and conn is not None:
|
conn_id = getattr(conn, "id", "") if conn is not None else ""
|
||||||
token = self._conn_tokens.get(getattr(conn, "id", ""), "")
|
if not token and conn_id:
|
||||||
|
token = self._conn_tokens.get(conn_id, "")
|
||||||
|
# Remember the token per connection so a later token-block on this flow
|
||||||
|
# can attribute its supervise proposal by (source_ip, identity_token)
|
||||||
|
# over RPC — plain-HTTP requests carry it here, HTTPS tunnels captured
|
||||||
|
# it at CONNECT (http_connect); both land in `_conn_tokens`.
|
||||||
|
if token and conn_id:
|
||||||
|
self._conn_tokens[conn_id] = token
|
||||||
return token
|
return token
|
||||||
|
|
||||||
def http_connect(self, flow: http.HTTPFlow) -> None:
|
def http_connect(self, flow: http.HTTPFlow) -> None:
|
||||||
@@ -592,42 +604,48 @@ class EgressAddon:
|
|||||||
redact_tokens(request_path, env=env),
|
redact_tokens(request_path, env=env),
|
||||||
result,
|
result,
|
||||||
)
|
)
|
||||||
proposal = _sv.Proposal.new(
|
# Attribute the proposal by (source_ip, identity_token) over the control
|
||||||
bottle_slug=slug,
|
# plane — the data plane no longer opens bot-bottle.db (PRD 0070 / #469).
|
||||||
tool=_sv.TOOL_EGRESS_TOKEN_ALLOW,
|
# source_ip + token come from this bottle's connection; `slug` is kept
|
||||||
proposed_file=payload,
|
# only for its own DLP safelist.
|
||||||
justification=_TOKEN_ALLOW_JUSTIFICATION,
|
conn = flow.client_conn
|
||||||
current_file_hash=_sv.sha256_hex(payload),
|
source_ip = conn.peername[0] if conn is not None and conn.peername else ""
|
||||||
)
|
token = self._conn_tokens.get(getattr(conn, "id", ""), "") if conn is not None else ""
|
||||||
try:
|
try:
|
||||||
_sv.write_proposal(proposal)
|
proposal_id = self._resolver.propose_supervise(
|
||||||
except OSError as e:
|
source_ip, token,
|
||||||
|
tool=TOOL_EGRESS_TOKEN_ALLOW,
|
||||||
|
proposed_file=payload,
|
||||||
|
justification=_TOKEN_ALLOW_JUSTIFICATION,
|
||||||
|
)
|
||||||
|
except PolicyResolveError as e:
|
||||||
sys.stderr.write(
|
sys.stderr.write(
|
||||||
f"egress: could not queue token-allow proposal: {e}; "
|
f"egress: could not queue token-allow proposal: {e}; "
|
||||||
"blocking request\n"
|
"blocking request\n"
|
||||||
)
|
)
|
||||||
|
proposal_id = None
|
||||||
|
if proposal_id is None:
|
||||||
self._block(flow, f"egress DLP: {result.reason}", ctx=self._req_ctx(flow))
|
self._block(flow, f"egress DLP: {result.reason}", ctx=self._req_ctx(flow))
|
||||||
return False
|
return False
|
||||||
|
|
||||||
sys.stderr.write(json.dumps({
|
sys.stderr.write(json.dumps({
|
||||||
"event": "egress_token_supervise",
|
"event": "egress_token_supervise",
|
||||||
"reason": f"egress DLP: {result.reason}",
|
"reason": f"egress DLP: {result.reason}",
|
||||||
"proposal": proposal.id,
|
"proposal": proposal_id,
|
||||||
**self._req_ctx(flow),
|
**self._req_ctx(flow),
|
||||||
}) + "\n")
|
}) + "\n")
|
||||||
|
|
||||||
response = await self._await_token_response(proposal.id, slug)
|
response = await self._await_token_response(proposal_id, source_ip, token)
|
||||||
_sv.archive_proposal(slug, proposal.id)
|
|
||||||
|
|
||||||
if response is not None and response.status in (
|
if response is not None and response.get("status") in (
|
||||||
_sv.STATUS_APPROVED, _sv.STATUS_MODIFIED,
|
STATUS_APPROVED, STATUS_MODIFIED,
|
||||||
):
|
):
|
||||||
self._safe_tokens_for(slug).add(result.matched)
|
self._safe_tokens_for(slug).add(result.matched)
|
||||||
if self._flow_log(flow) >= LOG_BLOCKS:
|
if self._flow_log(flow) >= LOG_BLOCKS:
|
||||||
sys.stderr.write(json.dumps({
|
sys.stderr.write(json.dumps({
|
||||||
"event": "egress_token_allowed",
|
"event": "egress_token_allowed",
|
||||||
"reason": f"egress DLP: {result.reason}",
|
"reason": f"egress DLP: {result.reason}",
|
||||||
"proposal": proposal.id,
|
"proposal": proposal_id,
|
||||||
**self._req_ctx(flow),
|
**self._req_ctx(flow),
|
||||||
}) + "\n")
|
}) + "\n")
|
||||||
return True
|
return True
|
||||||
@@ -645,19 +663,23 @@ class EgressAddon:
|
|||||||
async def _await_token_response(
|
async def _await_token_response(
|
||||||
self,
|
self,
|
||||||
proposal_id: str,
|
proposal_id: str,
|
||||||
slug: str,
|
source_ip: str,
|
||||||
) -> "_sv.Response | None":
|
token: str,
|
||||||
"""Poll the DB for the operator's response without blocking the
|
) -> "dict[str, object] | None":
|
||||||
proxy event loop. Returns the Response, or None on timeout."""
|
"""Poll the control plane for the operator's decision without blocking
|
||||||
|
the proxy event loop. Returns the terminal `{status, ...}` payload once
|
||||||
|
decided, or None on timeout. A transient orchestrator error (or a
|
||||||
|
`pending`/`unknown` status) is retried until the deadline, then fails
|
||||||
|
closed — the caller blocks the request."""
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
deadline = loop.time() + self._token_allow_timeout
|
deadline = loop.time() + self._token_allow_timeout
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
return _sv.read_response(slug, proposal_id)
|
result = self._resolver.poll_supervise(source_ip, token, proposal_id)
|
||||||
except (OSError, ValueError, KeyError):
|
except PolicyResolveError:
|
||||||
# Not written yet, or a partial/malformed write — retry until
|
result = None
|
||||||
# the deadline, then fail closed.
|
if result is not None and result.get("status") in STATUSES:
|
||||||
pass
|
return result
|
||||||
if loop.time() >= deadline:
|
if loop.time() >= deadline:
|
||||||
return None
|
return None
|
||||||
await asyncio.sleep(TOKEN_ALLOW_POLL_INTERVAL_SECONDS)
|
await asyncio.sleep(TOKEN_ALLOW_POLL_INTERVAL_SECONDS)
|
||||||
|
|||||||
@@ -272,21 +272,27 @@ supervise_gitleaks_allow() {
|
|||||||
|
|
||||||
proposal_id=$(
|
proposal_id=$(
|
||||||
PYTHONPATH="/app${PYTHONPATH:+:$PYTHONPATH}" GITLEAKS_ALLOW_REF="$ref" python3 - "$report_file" <<'PY'
|
PYTHONPATH="/app${PYTHONPATH:+:$PYTHONPATH}" GITLEAKS_ALLOW_REF="$ref" python3 - "$report_file" <<'PY'
|
||||||
import datetime
|
|
||||||
import hashlib
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Queue over the control-plane RPC — the git-gate data plane no longer opens
|
||||||
|
# bot-bottle.db (PRD 0070 / issue #469). Attribution is by (source_ip,
|
||||||
|
# identity_token), resolved server-side, so the proposal lands under the
|
||||||
|
# calling bottle exactly as a direct write once did.
|
||||||
try:
|
try:
|
||||||
import supervise as _sv
|
from bot_bottle.policy_resolver import PolicyResolver, PolicyResolveError
|
||||||
|
from bot_bottle.supervise_types import TOOL_GITLEAKS_ALLOW
|
||||||
except ImportError:
|
except ImportError:
|
||||||
from bot_bottle import supervise as _sv
|
from policy_resolver import PolicyResolver, PolicyResolveError
|
||||||
|
from supervise_types import TOOL_GITLEAKS_ALLOW
|
||||||
|
|
||||||
report_path = Path(sys.argv[1])
|
report_path = Path(sys.argv[1])
|
||||||
slug = os.environ.get("SUPERVISE_BOTTLE_SLUG", "")
|
source_ip = os.environ.get("SUPERVISE_SOURCE_IP", "")
|
||||||
if not slug:
|
token = os.environ.get("SUPERVISE_IDENTITY_TOKEN", "")
|
||||||
|
orch_url = os.environ.get("BOT_BOTTLE_ORCHESTRATOR_URL", "")
|
||||||
|
if not source_ip or not orch_url:
|
||||||
sys.exit(2)
|
sys.exit(2)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -323,19 +329,21 @@ for i, finding in enumerate(raw, 1):
|
|||||||
])
|
])
|
||||||
|
|
||||||
payload = "\n".join(lines).rstrip() + "\n"
|
payload = "\n".join(lines).rstrip() + "\n"
|
||||||
proposal = _sv.Proposal.new(
|
try:
|
||||||
bottle_slug=slug,
|
proposal_id = PolicyResolver(orch_url).propose_supervise(
|
||||||
tool=_sv.TOOL_GITLEAKS_ALLOW,
|
source_ip, token,
|
||||||
proposed_file=payload,
|
tool=TOOL_GITLEAKS_ALLOW,
|
||||||
justification=(
|
proposed_file=payload,
|
||||||
"git-gate found gitleaks findings hidden by # gitleaks:allow; "
|
justification=(
|
||||||
"approve only for dummy test fixtures or confirmed false positives"
|
"git-gate found gitleaks findings hidden by # gitleaks:allow; "
|
||||||
),
|
"approve only for dummy test fixtures or confirmed false positives"
|
||||||
current_file_hash=hashlib.sha256(payload.encode("utf-8")).hexdigest(),
|
),
|
||||||
now=datetime.datetime.now(datetime.timezone.utc),
|
)
|
||||||
)
|
except PolicyResolveError:
|
||||||
_sv.write_proposal(proposal)
|
sys.exit(4)
|
||||||
print(proposal.id)
|
if not proposal_id:
|
||||||
|
sys.exit(2)
|
||||||
|
print(proposal_id)
|
||||||
PY
|
PY
|
||||||
)
|
)
|
||||||
rc=$?
|
rc=$?
|
||||||
@@ -348,7 +356,6 @@ PY
|
|||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
slug=${SUPERVISE_BOTTLE_SLUG:-}
|
|
||||||
timeout=${SUPERVISE_GITLEAKS_ALLOW_TIMEOUT_SECONDS:-300}
|
timeout=${SUPERVISE_GITLEAKS_ALLOW_TIMEOUT_SECONDS:-300}
|
||||||
case "$timeout" in
|
case "$timeout" in
|
||||||
''|*[!0-9]*)
|
''|*[!0-9]*)
|
||||||
@@ -360,20 +367,30 @@ PY
|
|||||||
echo "git-gate: approve with './cli.py supervise' to continue this push" >&2
|
echo "git-gate: approve with './cli.py supervise' to continue this push" >&2
|
||||||
waited=0
|
waited=0
|
||||||
while [ "$waited" -lt "$timeout" ]; do
|
while [ "$waited" -lt "$timeout" ]; do
|
||||||
status=$(PYTHONPATH="/app${PYTHONPATH:+:$PYTHONPATH}" python3 - "$slug" "$proposal_id" <<'PY'
|
status=$(PYTHONPATH="/app${PYTHONPATH:+:$PYTHONPATH}" python3 - "$proposal_id" <<'PY'
|
||||||
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
|
# Non-blocking poll over the control plane. A decided proposal is archived
|
||||||
|
# server-side on read, so no separate archive step is needed here.
|
||||||
try:
|
try:
|
||||||
import supervise as _sv
|
from bot_bottle.policy_resolver import PolicyResolver, PolicyResolveError
|
||||||
except ImportError:
|
except ImportError:
|
||||||
from bot_bottle import supervise as _sv
|
from policy_resolver import PolicyResolver, PolicyResolveError
|
||||||
|
|
||||||
slug = sys.argv[1]
|
source_ip = os.environ.get("SUPERVISE_SOURCE_IP", "")
|
||||||
|
token = os.environ.get("SUPERVISE_IDENTITY_TOKEN", "")
|
||||||
|
orch_url = os.environ.get("BOT_BOTTLE_ORCHESTRATOR_URL", "")
|
||||||
try:
|
try:
|
||||||
response = _sv.read_response(slug, sys.argv[2])
|
result = PolicyResolver(orch_url).poll_supervise(source_ip, token, sys.argv[1])
|
||||||
except FileNotFoundError:
|
except PolicyResolveError:
|
||||||
sys.exit(2)
|
sys.exit(2)
|
||||||
print(response.status)
|
if result is None:
|
||||||
|
sys.exit(2)
|
||||||
|
status = result.get("status", "")
|
||||||
|
if status in ("pending", "unknown"):
|
||||||
|
sys.exit(2)
|
||||||
|
print(status)
|
||||||
PY
|
PY
|
||||||
)
|
)
|
||||||
rc=$?
|
rc=$?
|
||||||
@@ -385,16 +402,6 @@ PY
|
|||||||
if [ -n "$status" ]; then
|
if [ -n "$status" ]; then
|
||||||
case "$status" in
|
case "$status" in
|
||||||
approved|modified)
|
approved|modified)
|
||||||
PYTHONPATH="/app${PYTHONPATH:+:$PYTHONPATH}" python3 - "$slug" "$proposal_id" <<'PY' || true
|
|
||||||
import sys
|
|
||||||
|
|
||||||
try:
|
|
||||||
import supervise as _sv
|
|
||||||
except ImportError:
|
|
||||||
from bot_bottle import supervise as _sv
|
|
||||||
|
|
||||||
_sv.archive_proposal(sys.argv[1], sys.argv[2])
|
|
||||||
PY
|
|
||||||
echo "git-gate: supervisor approved # gitleaks:allow for $ref" >&2
|
echo "git-gate: supervisor approved # gitleaks:allow for $ref" >&2
|
||||||
return 0
|
return 0
|
||||||
;;
|
;;
|
||||||
|
|||||||
@@ -167,11 +167,14 @@ class GitHttpHandler(BaseHTTPRequestHandler):
|
|||||||
"SERVER_PORT": str(self.server.server_port), # type: ignore
|
"SERVER_PORT": str(self.server.server_port), # type: ignore
|
||||||
"SERVER_PROTOCOL": self.request_version,
|
"SERVER_PROTOCOL": self.request_version,
|
||||||
})
|
})
|
||||||
# Attribute the gitleaks-allow supervise proposal (written by
|
# Attribute the gitleaks-allow supervise proposal (queued by
|
||||||
# receive-pack's pre-receive hook, a child of the CGI we spawn below) to
|
# receive-pack's pre-receive hook, a child of the CGI we spawn below) to
|
||||||
# the calling bottle. The namespaced root is `<base>/<bottle_id>`, so its
|
# the calling bottle. The hook queues over the control-plane RPC (PRD
|
||||||
# final component is the bottle id — the same per-bottle key egress uses.
|
# 0070 / issue #469), which re-resolves the bottle from these — the same
|
||||||
env["SUPERVISE_BOTTLE_SLUG"] = sandbox_root.name
|
# (source_ip, identity_token) pair that selected the namespaced root
|
||||||
|
# above — so it can only ever queue its own proposals.
|
||||||
|
env["SUPERVISE_SOURCE_IP"] = self.client_address[0]
|
||||||
|
env["SUPERVISE_IDENTITY_TOKEN"] = self.headers.get(IDENTITY_HEADER, "")
|
||||||
for header, variable in (
|
for header, variable in (
|
||||||
("accept", "HTTP_ACCEPT"),
|
("accept", "HTTP_ACCEPT"),
|
||||||
("content-encoding", "HTTP_CONTENT_ENCODING"),
|
("content-encoding", "HTTP_CONTENT_ENCODING"),
|
||||||
|
|||||||
@@ -27,6 +27,18 @@ vsock / unix-socket portability caveats):
|
|||||||
POST /supervise/respond -> 200 {"responded": true} | 409 (operator)
|
POST /supervise/respond -> 200 {"responded": true} | 409 (operator)
|
||||||
body: {"proposal_id","bottle_slug",
|
body: {"proposal_id","bottle_slug",
|
||||||
"decision", ["notes"],["final_file"]}
|
"decision", ["notes"],["final_file"]}
|
||||||
|
POST /supervise/propose -> 201 {"proposal_id"} | 403 (agent)
|
||||||
|
body: {"source_ip","identity_token",
|
||||||
|
"tool","proposed_file","justification"}
|
||||||
|
POST /supervise/poll -> 200 {"status", ["notes"],["final_file"]} | 403
|
||||||
|
body: {"source_ip","identity_token",
|
||||||
|
"proposal_id"}
|
||||||
|
|
||||||
|
The `/supervise/propose` + `/supervise/poll` pair is the **agent** half of the
|
||||||
|
supervise flow: the data plane (supervise / egress / git-gate) queues a
|
||||||
|
proposal and polls for its response over RPC instead of opening `bot-bottle.db`
|
||||||
|
directly. Both attribute the caller by `(source_ip, identity_token)` exactly
|
||||||
|
like `/resolve`, so a bottle can only ever queue or read its own proposals.
|
||||||
|
|
||||||
`POST /bottles` / `DELETE` drive the full launch lifecycle: they mint (or
|
`POST /bottles` / `DELETE` drive the full launch lifecycle: they mint (or
|
||||||
tear down) the bottle in the registry AND broker the backend-native launch
|
tear down) the bottle in the registry AND broker the backend-native launch
|
||||||
@@ -51,6 +63,7 @@ import typing
|
|||||||
from urllib.parse import urlsplit
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
from ..paths import CONTROL_PLANE_TOKEN_ENV
|
from ..paths import CONTROL_PLANE_TOKEN_ENV
|
||||||
|
from ..supervise_types import TOOLS
|
||||||
from .service import Orchestrator
|
from .service import Orchestrator
|
||||||
|
|
||||||
# JSON body payload type (parsed request / rendered response).
|
# JSON body payload type (parsed request / rendered response).
|
||||||
@@ -235,6 +248,56 @@ def dispatch( # pylint: disable=too-many-return-statements,too-many-branches
|
|||||||
return 200, {"responded": True}
|
return 200, {"responded": True}
|
||||||
return 409, {"error": err}
|
return 409, {"error": err}
|
||||||
|
|
||||||
|
if method == "POST" and route == "/supervise/propose":
|
||||||
|
# Agent half: queue a proposal, attributed to the caller resolved from
|
||||||
|
# (source_ip, identity_token) — never a caller-supplied slug — so the
|
||||||
|
# data plane can't forge attribution. Fail-closed 403 when unattributed.
|
||||||
|
try:
|
||||||
|
data = _parse_json_object(body)
|
||||||
|
except ValueError as e:
|
||||||
|
return 400, {"error": f"invalid JSON: {e}"}
|
||||||
|
source_ip = data.get("source_ip")
|
||||||
|
token = data.get("identity_token")
|
||||||
|
tool = data.get("tool")
|
||||||
|
proposed_file = data.get("proposed_file")
|
||||||
|
justification = data.get("justification")
|
||||||
|
if not isinstance(source_ip, str) or not source_ip:
|
||||||
|
return 400, {"error": "source_ip (string) is required"}
|
||||||
|
if not isinstance(tool, str) or tool not in TOOLS:
|
||||||
|
return 400, {"error": f"tool (string) must be one of {TOOLS}"}
|
||||||
|
if not isinstance(proposed_file, str) or not proposed_file:
|
||||||
|
return 400, {"error": "proposed_file (string) is required"}
|
||||||
|
if not isinstance(justification, str) or not justification:
|
||||||
|
return 400, {"error": "justification (string) is required"}
|
||||||
|
rec = orch.resolve(source_ip, token if isinstance(token, str) else "")
|
||||||
|
if rec is None:
|
||||||
|
return 403, {"error": "unattributed"}
|
||||||
|
proposal_id = orch.supervise_queue_proposal(
|
||||||
|
rec.bottle_id, tool=tool, proposed_file=proposed_file,
|
||||||
|
justification=justification,
|
||||||
|
)
|
||||||
|
return 201, {"proposal_id": proposal_id}
|
||||||
|
|
||||||
|
if method == "POST" and route == "/supervise/poll":
|
||||||
|
# Agent half: non-blocking read of the caller's own proposal decision.
|
||||||
|
# Attributed like /propose, and scoped to the resolved bottle id, so a
|
||||||
|
# guessed proposal_id can never read another bottle's response.
|
||||||
|
try:
|
||||||
|
data = _parse_json_object(body)
|
||||||
|
except ValueError as e:
|
||||||
|
return 400, {"error": f"invalid JSON: {e}"}
|
||||||
|
source_ip = data.get("source_ip")
|
||||||
|
token = data.get("identity_token")
|
||||||
|
proposal_id = data.get("proposal_id")
|
||||||
|
if not isinstance(source_ip, str) or not source_ip:
|
||||||
|
return 400, {"error": "source_ip (string) is required"}
|
||||||
|
if not isinstance(proposal_id, str) or not proposal_id:
|
||||||
|
return 400, {"error": "proposal_id (string) is required"}
|
||||||
|
rec = orch.resolve(source_ip, token if isinstance(token, str) else "")
|
||||||
|
if rec is None:
|
||||||
|
return 403, {"error": "unattributed"}
|
||||||
|
return 200, orch.supervise_poll_response(rec.bottle_id, proposal_id)
|
||||||
|
|
||||||
if method == "POST" and route == "/resolve":
|
if method == "POST" and route == "/resolve":
|
||||||
# The per-request lookup the multi-tenant gateway makes: returns the
|
# The per-request lookup the multi-tenant gateway makes: returns the
|
||||||
# bottle's policy. Requires a matching (source_ip, identity_token)
|
# bottle's policy. Requires a matching (source_ip, identity_token)
|
||||||
|
|||||||
@@ -26,15 +26,8 @@ from ..docker_cmd import run_docker
|
|||||||
from ..paths import (
|
from ..paths import (
|
||||||
CONTROL_PLANE_TOKEN_ENV,
|
CONTROL_PLANE_TOKEN_ENV,
|
||||||
host_control_plane_token,
|
host_control_plane_token,
|
||||||
host_db_path,
|
|
||||||
host_gateway_ca_dir,
|
host_gateway_ca_dir,
|
||||||
)
|
)
|
||||||
from ..supervise import DB_PATH_IN_CONTAINER
|
|
||||||
|
|
||||||
# The host DB dir is bind-mounted here so the gateway's supervise daemon
|
|
||||||
# writes its queued proposals into the ONE host DB (the same file the
|
|
||||||
# orchestrator container opens and the operator reaches over HTTP).
|
|
||||||
_SUPERVISE_DB_DIR_IN_CONTAINER = os.path.dirname(DB_PATH_IN_CONTAINER)
|
|
||||||
|
|
||||||
# The gateway's mitmproxy writes its CA a beat after the container starts, so
|
# The gateway's mitmproxy writes its CA a beat after the container starts, so
|
||||||
# reads poll for it rather than assuming it's there on a fresh launch.
|
# reads poll for it rather than assuming it's there on a fresh launch.
|
||||||
@@ -75,14 +68,6 @@ GATEWAY_DOCKERFILE = "Dockerfile.gateway"
|
|||||||
_REPO_ROOT = Path(__file__).resolve().parents[2]
|
_REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
|
||||||
|
|
||||||
def _host_db_dir() -> str:
|
|
||||||
"""The host DB directory (created if missing), for the gateway's
|
|
||||||
supervise-DB bind-mount."""
|
|
||||||
db_dir = host_db_path().parent
|
|
||||||
db_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
return str(db_dir)
|
|
||||||
|
|
||||||
|
|
||||||
def rotate_gateway_ca(ca_dir: Path | None = None) -> list[Path]:
|
def rotate_gateway_ca(ca_dir: Path | None = None) -> list[Path]:
|
||||||
"""Delete the persisted mitmproxy CA so the next gateway start mints a
|
"""Delete the persisted mitmproxy CA so the next gateway start mints a
|
||||||
fresh one — the explicit, deliberate CA-rollover path (issue #450).
|
fresh one — the explicit, deliberate CA-rollover path (issue #450).
|
||||||
@@ -255,11 +240,10 @@ class DockerGateway(Gateway):
|
|||||||
# container recreation AND docker volume pruning (agents trust it)
|
# container recreation AND docker volume pruning (agents trust it)
|
||||||
# — see host_gateway_ca_dir / issue #450.
|
# — see host_gateway_ca_dir / issue #450.
|
||||||
"--volume", f"{host_gateway_ca_dir()}:{MITMPROXY_HOME}",
|
"--volume", f"{host_gateway_ca_dir()}:{MITMPROXY_HOME}",
|
||||||
# Share the one host DB: the supervise daemon queues proposals
|
# No DB mount: the data plane (egress / supervise / git-gate) reaches
|
||||||
# into the same file the orchestrator (and the operator, over
|
# the supervise queue over the control-plane RPC and never opens
|
||||||
# HTTP) reads — no second, disconnected DB in the container.
|
# bot-bottle.db, so the gateway container gets no file handle on it
|
||||||
"--volume", f"{_host_db_dir()}:{_SUPERVISE_DB_DIR_IN_CONTAINER}",
|
# (PRD 0070 / issue #469).
|
||||||
"--env", f"SUPERVISE_DB_PATH={DB_PATH_IN_CONTAINER}",
|
|
||||||
]
|
]
|
||||||
for port in self._host_port_bindings:
|
for port in self._host_port_bindings:
|
||||||
argv += ["--publish", f"0.0.0.0:{port}:{port}"]
|
argv += ["--publish", f"0.0.0.0:{port}:{port}"]
|
||||||
|
|||||||
@@ -29,14 +29,12 @@ from ..paths import (
|
|||||||
host_control_plane_token,
|
host_control_plane_token,
|
||||||
host_gateway_ca_dir,
|
host_gateway_ca_dir,
|
||||||
)
|
)
|
||||||
from ..supervise import DB_PATH_IN_CONTAINER
|
|
||||||
from .gateway import (
|
from .gateway import (
|
||||||
GATEWAY_DOCKERFILE,
|
GATEWAY_DOCKERFILE,
|
||||||
GATEWAY_IMAGE,
|
GATEWAY_IMAGE,
|
||||||
GATEWAY_NETWORK,
|
GATEWAY_NETWORK,
|
||||||
GatewayError,
|
GatewayError,
|
||||||
MITMPROXY_HOME,
|
MITMPROXY_HOME,
|
||||||
_host_db_dir,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
DEFAULT_PORT = 8099
|
DEFAULT_PORT = 8099
|
||||||
@@ -69,12 +67,12 @@ _INFRA_DAEMONS = "egress,git-http,supervise,orchestrator"
|
|||||||
# container. Separate from /app so the gateway's baked scripts
|
# container. Separate from /app so the gateway's baked scripts
|
||||||
# (egress_addon.py, egress-entrypoint.sh) are not overlaid.
|
# (egress_addon.py, egress-entrypoint.sh) are not overlaid.
|
||||||
_SRC_IN_CONTAINER = "/bot-bottle-src"
|
_SRC_IN_CONTAINER = "/bot-bottle-src"
|
||||||
# Bot-bottle host-root bind-mount inside the container (DB + state).
|
# Bot-bottle host-root bind-mount inside the container (DB + state). The
|
||||||
|
# orchestrator control plane opens bot-bottle.db under here (via BOT_BOTTLE_ROOT
|
||||||
|
# -> host_db_path()); it is the ONLY process in the container with a file
|
||||||
|
# handle on it (PRD 0070 / issue #469).
|
||||||
_ROOT_IN_CONTAINER = "/bot-bottle-root"
|
_ROOT_IN_CONTAINER = "/bot-bottle-root"
|
||||||
|
|
||||||
# The supervise daemon writes proposals into the host DB directory.
|
|
||||||
_SUPERVISE_DB_DIR_IN_CONTAINER = os.path.dirname(DB_PATH_IN_CONTAINER)
|
|
||||||
|
|
||||||
_HEALTH_POLL_SECONDS = 0.25
|
_HEALTH_POLL_SECONDS = 0.25
|
||||||
_HEALTH_REQUEST_TIMEOUT_SECONDS = 1.0
|
_HEALTH_REQUEST_TIMEOUT_SECONDS = 1.0
|
||||||
|
|
||||||
@@ -202,9 +200,6 @@ class OrchestratorService:
|
|||||||
# recreation AND docker volume pruning (issue #450): every agent
|
# recreation AND docker volume pruning (issue #450): every agent
|
||||||
# trusts this one CA, so a fresh one would break all running bottles.
|
# trusts this one CA, so a fresh one would break all running bottles.
|
||||||
"--volume", f"{host_gateway_ca_dir()}:{MITMPROXY_HOME}",
|
"--volume", f"{host_gateway_ca_dir()}:{MITMPROXY_HOME}",
|
||||||
# Shared supervise DB (same file the operator reads over HTTP).
|
|
||||||
"--volume", f"{_host_db_dir()}:{_SUPERVISE_DB_DIR_IN_CONTAINER}",
|
|
||||||
"--env", f"SUPERVISE_DB_PATH={DB_PATH_IN_CONTAINER}",
|
|
||||||
# Live control-plane source, mounted to a path that does not
|
# Live control-plane source, mounted to a path that does not
|
||||||
# overlay the gateway's baked /app scripts.
|
# overlay the gateway's baked /app scripts.
|
||||||
"--volume", f"{self._repo_root}:{_SRC_IN_CONTAINER}:ro",
|
"--volume", f"{self._repo_root}:{_SRC_IN_CONTAINER}:ro",
|
||||||
|
|||||||
@@ -31,16 +31,23 @@ from .gateway import Gateway
|
|||||||
from ..supervise import (
|
from ..supervise import (
|
||||||
AuditEntry,
|
AuditEntry,
|
||||||
COMPONENT_FOR_TOOL,
|
COMPONENT_FOR_TOOL,
|
||||||
|
POLL_STATUS_PENDING,
|
||||||
|
POLL_STATUS_UNKNOWN,
|
||||||
|
Proposal,
|
||||||
Response,
|
Response,
|
||||||
STATUS_APPROVED,
|
STATUS_APPROVED,
|
||||||
STATUS_MODIFIED,
|
STATUS_MODIFIED,
|
||||||
STATUS_REJECTED,
|
STATUS_REJECTED,
|
||||||
TOOL_EGRESS_ALLOW,
|
TOOL_EGRESS_ALLOW,
|
||||||
TOOL_EGRESS_BLOCK,
|
TOOL_EGRESS_BLOCK,
|
||||||
|
archive_proposal,
|
||||||
list_all_pending_proposals,
|
list_all_pending_proposals,
|
||||||
read_proposal,
|
read_proposal,
|
||||||
|
read_response,
|
||||||
render_diff,
|
render_diff,
|
||||||
|
sha256_hex,
|
||||||
write_audit_entry,
|
write_audit_entry,
|
||||||
|
write_proposal,
|
||||||
write_response,
|
write_response,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -188,6 +195,60 @@ class Orchestrator:
|
|||||||
# The orchestrator owns the single DB *and* the live policy, so operator
|
# The orchestrator owns the single DB *and* the live policy, so operator
|
||||||
# decisions are applied here, server-side, and reached over HTTP by the
|
# decisions are applied here, server-side, and reached over HTTP by the
|
||||||
# host TUI (no direct-DB access, one path for every backend).
|
# host TUI (no direct-DB access, one path for every backend).
|
||||||
|
#
|
||||||
|
# The *agent* half of the queue (queue a proposal, poll for its response)
|
||||||
|
# is served here too (PRD 0070): the data plane no longer opens the DB, so
|
||||||
|
# supervise_server / egress / git-gate reach the queue over the control
|
||||||
|
# plane. Both are keyed on the caller's orchestrator-resolved bottle id —
|
||||||
|
# never a caller-supplied slug — so a bottle can only queue or read its own
|
||||||
|
# proposals even if the data plane is compromised.
|
||||||
|
|
||||||
|
def supervise_queue_proposal(
|
||||||
|
self, bottle_id: str, *, tool: str, proposed_file: str, justification: str,
|
||||||
|
) -> str:
|
||||||
|
"""Queue an agent-side proposal under `bottle_id` and return its id.
|
||||||
|
|
||||||
|
`bottle_id` is the caller resolved from `(source_ip, identity_token)` by
|
||||||
|
the control plane, so the proposal is attributed to the calling bottle,
|
||||||
|
not to anything the (possibly hostile) data plane asserts. The
|
||||||
|
proposed-file self-hash is computed here so the agent never supplies it."""
|
||||||
|
proposal = Proposal.new(
|
||||||
|
bottle_slug=bottle_id,
|
||||||
|
tool=tool,
|
||||||
|
proposed_file=proposed_file,
|
||||||
|
justification=justification,
|
||||||
|
current_file_hash=sha256_hex(proposed_file),
|
||||||
|
)
|
||||||
|
write_proposal(proposal)
|
||||||
|
return proposal.id
|
||||||
|
|
||||||
|
def supervise_poll_response(self, bottle_id: str, proposal_id: str) -> dict[str, object]:
|
||||||
|
"""Non-blocking poll of one of `bottle_id`'s queued proposals.
|
||||||
|
|
||||||
|
Returns `{"status": ...}`: a terminal decision (`approved`/`modified`/
|
||||||
|
`rejected`, with `notes` + `final_file`) once the operator has responded,
|
||||||
|
`pending` while it's still queued undecided, or `unknown` when no such
|
||||||
|
queued proposal exists for this bottle (wrong id, or already resolved and
|
||||||
|
read). A decided proposal is archived here — the same terminal step the
|
||||||
|
data plane used to run after reading the response — so `pending`
|
||||||
|
proposals stay visible to the operator until decided *and* polled.
|
||||||
|
|
||||||
|
Reads are scoped to `bottle_id` (the queue key), so a caller can never
|
||||||
|
read another bottle's proposal even with a guessed id."""
|
||||||
|
try:
|
||||||
|
response = read_response(bottle_id, proposal_id)
|
||||||
|
except FileNotFoundError:
|
||||||
|
try:
|
||||||
|
read_proposal(bottle_id, proposal_id)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return {"status": POLL_STATUS_UNKNOWN}
|
||||||
|
return {"status": POLL_STATUS_PENDING}
|
||||||
|
archive_proposal(bottle_id, proposal_id)
|
||||||
|
return {
|
||||||
|
"status": response.status,
|
||||||
|
"notes": response.notes,
|
||||||
|
"final_file": response.final_file,
|
||||||
|
}
|
||||||
|
|
||||||
def supervise_pending(self) -> list[dict[str, object]]:
|
def supervise_pending(self) -> list[dict[str, object]]:
|
||||||
"""All pending proposals across bottles, FIFO, as JSON dicts
|
"""All pending proposals across bottles, FIFO, as JSON dicts
|
||||||
|
|||||||
@@ -64,28 +64,80 @@ class PolicyResolver:
|
|||||||
self._base = base_url.rstrip("/")
|
self._base = base_url.rstrip("/")
|
||||||
self._timeout = timeout
|
self._timeout = timeout
|
||||||
|
|
||||||
|
def _post_json(self, path: str, payload: dict[str, object]) -> dict[str, object] | None:
|
||||||
|
"""POST `payload` to control-plane `path`, returning the JSON object body
|
||||||
|
— or None when the orchestrator answers `403` (unattributed / fail
|
||||||
|
closed). Raises `PolicyResolveError` on an unreachable / unexpected-status
|
||||||
|
/ malformed response so every caller can fail closed. Shared by
|
||||||
|
`_post_resolve` and the supervise propose/poll RPCs."""
|
||||||
|
body = json.dumps(payload).encode()
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"{self._base}{path}", data=body, method="POST",
|
||||||
|
headers={"Content-Type": "application/json", **_control_auth_headers()},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=self._timeout) as resp:
|
||||||
|
data = json.loads(resp.read())
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
if e.code == 403:
|
||||||
|
return None # unattributed → fail closed (caller denies)
|
||||||
|
raise PolicyResolveError(f"{path} returned HTTP {e.code}") from e
|
||||||
|
except (urllib.error.URLError, TimeoutError, OSError, ValueError) as e:
|
||||||
|
raise PolicyResolveError(f"{path} unreachable or malformed: {e}") from e
|
||||||
|
return data if isinstance(data, dict) else {}
|
||||||
|
|
||||||
def _post_resolve(self, source_ip: str, identity_token: str) -> dict[str, object] | None:
|
def _post_resolve(self, source_ip: str, identity_token: str) -> dict[str, object] | None:
|
||||||
"""The orchestrator's `/resolve` payload for this client, or None if
|
"""The orchestrator's `/resolve` payload for this client, or None if
|
||||||
unattributed (a clean `403`). Raises `PolicyResolveError` on an
|
unattributed (a clean `403`). Raises `PolicyResolveError` on an
|
||||||
unreachable / unexpected-status / malformed response so every caller
|
unreachable / unexpected-status / malformed response so every caller
|
||||||
can fail closed. Shared by `resolve` and `resolve_bottle_id`."""
|
can fail closed. Shared by `resolve` and `resolve_bottle_id`."""
|
||||||
body = json.dumps(
|
return self._post_json(
|
||||||
{"source_ip": source_ip, "identity_token": identity_token}
|
"/resolve", {"source_ip": source_ip, "identity_token": identity_token},
|
||||||
).encode()
|
|
||||||
req = urllib.request.Request(
|
|
||||||
f"{self._base}/resolve", data=body, method="POST",
|
|
||||||
headers={"Content-Type": "application/json", **_control_auth_headers()},
|
|
||||||
)
|
)
|
||||||
try:
|
|
||||||
with urllib.request.urlopen(req, timeout=self._timeout) as resp:
|
def propose_supervise(
|
||||||
payload = json.loads(resp.read())
|
self,
|
||||||
except urllib.error.HTTPError as e:
|
source_ip: str,
|
||||||
if e.code == 403:
|
identity_token: str,
|
||||||
return None # unattributed → fail closed (caller denies)
|
*,
|
||||||
raise PolicyResolveError(f"/resolve returned HTTP {e.code}") from e
|
tool: str,
|
||||||
except (urllib.error.URLError, TimeoutError, OSError, ValueError) as e:
|
proposed_file: str,
|
||||||
raise PolicyResolveError(f"/resolve unreachable or malformed: {e}") from e
|
justification: str,
|
||||||
return payload if isinstance(payload, dict) else {}
|
) -> str | None:
|
||||||
|
"""Queue a supervise proposal on the control plane and return its
|
||||||
|
`proposal_id`. The orchestrator attributes the proposal to the calling
|
||||||
|
bottle by `(source_ip, identity_token)` — exactly like `/resolve` — so a
|
||||||
|
bottle can only ever queue its *own* proposals. Returns None when the
|
||||||
|
pair is unattributed (a clean `403`); raises `PolicyResolveError` if the
|
||||||
|
orchestrator can't be reached, so the data-plane caller fails closed
|
||||||
|
(blocks / refuses) rather than silently dropping the proposal."""
|
||||||
|
payload = self._post_json("/supervise/propose", {
|
||||||
|
"source_ip": source_ip,
|
||||||
|
"identity_token": identity_token,
|
||||||
|
"tool": tool,
|
||||||
|
"proposed_file": proposed_file,
|
||||||
|
"justification": justification,
|
||||||
|
})
|
||||||
|
if payload is None:
|
||||||
|
return None
|
||||||
|
proposal_id = payload.get("proposal_id")
|
||||||
|
return proposal_id if isinstance(proposal_id, str) and proposal_id else None
|
||||||
|
|
||||||
|
def poll_supervise(
|
||||||
|
self, source_ip: str, identity_token: str, proposal_id: str,
|
||||||
|
) -> dict[str, object] | None:
|
||||||
|
"""Poll a queued proposal for the operator's decision, **non-blocking**.
|
||||||
|
Attributed by `(source_ip, identity_token)` so a bottle can only read its
|
||||||
|
*own* proposal's response. Returns `{"status": ...}` where status is one
|
||||||
|
of the terminal decisions (`approved`/`modified`/`rejected`, carrying
|
||||||
|
`notes` + `final_file`), `pending` (queued, no decision yet), or
|
||||||
|
`unknown` (no such queued proposal for this bottle). Returns None when
|
||||||
|
unattributed; raises `PolicyResolveError` if unreachable."""
|
||||||
|
return self._post_json("/supervise/poll", {
|
||||||
|
"source_ip": source_ip,
|
||||||
|
"identity_token": identity_token,
|
||||||
|
"proposal_id": proposal_id,
|
||||||
|
})
|
||||||
|
|
||||||
def resolve(self, source_ip: str, identity_token: str = "") -> str | None:
|
def resolve(self, source_ip: str, identity_token: str = "") -> str | None:
|
||||||
"""The calling bottle's policy blob, or None if unattributed. Always
|
"""The calling bottle's policy blob, or None if unattributed. Always
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ from pathlib import Path
|
|||||||
from .supervise_types import (
|
from .supervise_types import (
|
||||||
ACTION_OPERATOR_EDIT,
|
ACTION_OPERATOR_EDIT,
|
||||||
AuditEntry,
|
AuditEntry,
|
||||||
|
POLL_STATUS_PENDING,
|
||||||
|
POLL_STATUS_UNKNOWN,
|
||||||
Proposal,
|
Proposal,
|
||||||
Response,
|
Response,
|
||||||
STATUSES,
|
STATUSES,
|
||||||
@@ -257,6 +259,8 @@ __all__ = [
|
|||||||
"STATUS_APPROVED",
|
"STATUS_APPROVED",
|
||||||
"STATUS_MODIFIED",
|
"STATUS_MODIFIED",
|
||||||
"STATUS_REJECTED",
|
"STATUS_REJECTED",
|
||||||
|
"POLL_STATUS_PENDING",
|
||||||
|
"POLL_STATUS_UNKNOWN",
|
||||||
"SUPERVISE_HOSTNAME",
|
"SUPERVISE_HOSTNAME",
|
||||||
"SUPERVISE_PORT",
|
"SUPERVISE_PORT",
|
||||||
"Supervise",
|
"Supervise",
|
||||||
|
|||||||
+154
-107
@@ -7,9 +7,13 @@ config changes when stuck. The tools are `egress-allow`,
|
|||||||
Each queued proposal 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. Queues a Proposal over the control-plane RPC (`POST /supervise/propose`),
|
||||||
3. Blocks polling for a matching Response row, up to a short grace
|
which attributes it to the calling bottle by (source_ip, identity_token)
|
||||||
window (`SUPERVISE_RESPONSE_TIMEOUT_SECONDS`, default 30s).
|
and writes it to the one orchestrator-owned SQLite database. This daemon
|
||||||
|
never opens `bot-bottle.db` itself (PRD 0070 / issue #469).
|
||||||
|
3. Polls the control plane (`POST /supervise/poll`) for a matching Response,
|
||||||
|
up to a short grace window (`SUPERVISE_RESPONSE_TIMEOUT_SECONDS`,
|
||||||
|
default 30s).
|
||||||
4. On a decision within the window, returns the operator's
|
4. On a decision within the window, returns the operator's
|
||||||
`{status, notes}`. On timeout, returns `status: pending` **with the
|
`{status, notes}`. On timeout, returns `status: pending` **with the
|
||||||
proposal id** and leaves the proposal queued — the flow is
|
proposal id** and leaves the proposal queued — the flow is
|
||||||
@@ -24,8 +28,8 @@ 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
|
||||||
— an unattributed or unreachable source fails closed. BOT_BOTTLE_ORCHESTRATOR_URL
|
— an unattributed or unreachable source fails closed. BOT_BOTTLE_ORCHESTRATOR_URL
|
||||||
is mandatory: there is no fixed-slug single-tenant fallback. SUPERVISE_DB_PATH
|
is mandatory: there is no fixed-slug single-tenant fallback, and the queue is
|
||||||
points at the bind-mounted host database.
|
reached only over that control-plane RPC (no direct database handle).
|
||||||
|
|
||||||
Speaks MCP over HTTP+JSON-RPC. Methods handled:
|
Speaks MCP over HTTP+JSON-RPC. Methods handled:
|
||||||
|
|
||||||
@@ -51,7 +55,7 @@ import socketserver
|
|||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
import typing
|
import typing
|
||||||
from dataclasses import dataclass, replace
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from bot_bottle.constants import IDENTITY_HEADER
|
from bot_bottle.constants import IDENTITY_HEADER
|
||||||
from bot_bottle.egress_addon_core import (
|
from bot_bottle.egress_addon_core import (
|
||||||
@@ -312,7 +316,9 @@ def validate_proposed_file(tool: str, content: str) -> None:
|
|||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class ServerConfig:
|
class ServerConfig:
|
||||||
bottle_slug: str
|
# No bottle_slug: the calling bottle is attributed per request by the
|
||||||
|
# control plane from (source_ip, identity_token), so nothing on this daemon
|
||||||
|
# is keyed by a single slug (PRD 0070 / issue #469).
|
||||||
response_timeout_seconds: float = DEFAULT_RESPONSE_TIMEOUT_SECONDS
|
response_timeout_seconds: float = DEFAULT_RESPONSE_TIMEOUT_SECONDS
|
||||||
|
|
||||||
|
|
||||||
@@ -331,14 +337,22 @@ def handle_tools_list(_params: dict[str, object]) -> dict[str, object]:
|
|||||||
def handle_tools_call(
|
def handle_tools_call(
|
||||||
params: dict[str, object],
|
params: dict[str, object],
|
||||||
config: ServerConfig,
|
config: ServerConfig,
|
||||||
|
*,
|
||||||
|
resolver: "PolicyResolver",
|
||||||
|
source_ip: str,
|
||||||
|
identity_token: str,
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
"""Validates the proposal, writes it to the queue, blocks waiting
|
"""Validates the proposal, queues it over the control-plane RPC, polls for
|
||||||
for a Response, returns the result wrapped in MCP `content`.
|
a Response through the grace window, and returns the result wrapped in MCP
|
||||||
|
`content`.
|
||||||
|
|
||||||
`list-egress-routes` never reaches here — the handler answers it from
|
The queue lives behind the orchestrator now — `propose_supervise` attributes
|
||||||
the calling bottle's resolved policy before dispatching (see
|
the proposal to the calling bottle by `(source_ip, identity_token)` and
|
||||||
`MCPHandler._dispatch`); this path is the queued, operator-approved
|
`poll_supervise` reads only that bottle's own responses, so this daemon
|
||||||
`egress-allow` / `egress-block` tools."""
|
never opens `bot-bottle.db`. `list-egress-routes` never reaches here — the
|
||||||
|
handler answers it from the calling bottle's resolved policy before
|
||||||
|
dispatching (see `MCPHandler._dispatch`); this path is the queued,
|
||||||
|
operator-approved `egress-allow` / `egress-block` tools."""
|
||||||
name = params.get("name")
|
name = params.get("name")
|
||||||
if not isinstance(name, str):
|
if not isinstance(name, str):
|
||||||
raise _RpcClientError(ERR_INVALID_PARAMS, "tools/call missing 'name'")
|
raise _RpcClientError(ERR_INVALID_PARAMS, "tools/call missing 'name'")
|
||||||
@@ -366,59 +380,100 @@ def handle_tools_call(
|
|||||||
else:
|
else:
|
||||||
raise _RpcClientError(ERR_INVALID_PARAMS, f"unknown tool {name!r}")
|
raise _RpcClientError(ERR_INVALID_PARAMS, f"unknown tool {name!r}")
|
||||||
|
|
||||||
proposal = _sv.Proposal.new(
|
proposal_id = _queue_proposal(
|
||||||
bottle_slug=config.bottle_slug,
|
resolver, source_ip, identity_token,
|
||||||
tool=name,
|
tool=name, proposed_file=proposed_file, justification=justification,
|
||||||
proposed_file=proposed_file,
|
|
||||||
justification=justification,
|
|
||||||
current_file_hash=_sv.sha256_hex(proposed_file),
|
|
||||||
)
|
)
|
||||||
try:
|
|
||||||
_sv.write_proposal(proposal)
|
|
||||||
except OSError as e:
|
|
||||||
raise _RpcInternalError(f"failed to write proposal to queue: {e}") from e
|
|
||||||
sys.stderr.write(
|
sys.stderr.write(
|
||||||
f"supervise: queued proposal {proposal.id} ({name}) "
|
f"supervise: queued proposal {proposal_id} ({name}); waiting for operator...\n"
|
||||||
f"for bottle {config.bottle_slug}; waiting for operator...\n"
|
|
||||||
)
|
)
|
||||||
sys.stderr.flush()
|
sys.stderr.flush()
|
||||||
deadline = time.monotonic() + config.response_timeout_seconds
|
|
||||||
try:
|
|
||||||
response = _sv.wait_for_response(
|
|
||||||
config.bottle_slug,
|
|
||||||
proposal.id,
|
|
||||||
poll_interval=MIN_RESPONSE_POLL_INTERVAL_SECONDS,
|
|
||||||
deadline=deadline,
|
|
||||||
)
|
|
||||||
except TimeoutError:
|
|
||||||
text = format_pending_response_text(proposal.id, config.response_timeout_seconds)
|
|
||||||
return {
|
|
||||||
"content": [{"type": "text", "text": text}],
|
|
||||||
"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
|
|
||||||
|
|
||||||
text = format_response_text(response)
|
deadline = time.monotonic() + config.response_timeout_seconds
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
response = _poll_response(resolver, source_ip, identity_token, proposal_id)
|
||||||
|
if response is not None:
|
||||||
|
return {
|
||||||
|
"content": [{"type": "text", "text": format_response_text(response)}],
|
||||||
|
"isError": response.status == _sv.STATUS_REJECTED,
|
||||||
|
}
|
||||||
|
time.sleep(MIN_RESPONSE_POLL_INTERVAL_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": response.status == _sv.STATUS_REJECTED,
|
"isError": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _queue_proposal(
|
||||||
|
resolver: "PolicyResolver",
|
||||||
|
source_ip: str,
|
||||||
|
identity_token: str,
|
||||||
|
*,
|
||||||
|
tool: str,
|
||||||
|
proposed_file: str,
|
||||||
|
justification: str,
|
||||||
|
) -> str:
|
||||||
|
"""Queue a proposal over the control plane and return its id, mapping the
|
||||||
|
resolver's fail-closed outcomes to internal errors so `do_POST` returns a
|
||||||
|
clean -32603 rather than leaking the cause to the agent."""
|
||||||
|
try:
|
||||||
|
proposal_id = resolver.propose_supervise(
|
||||||
|
source_ip, identity_token,
|
||||||
|
tool=tool, proposed_file=proposed_file, justification=justification,
|
||||||
|
)
|
||||||
|
except PolicyResolveError as e:
|
||||||
|
raise _RpcInternalError(f"orchestrator unreachable, cannot queue proposal: {e}") from e
|
||||||
|
if proposal_id is None:
|
||||||
|
raise _RpcInternalError("request source is not attributed to a bottle")
|
||||||
|
return proposal_id
|
||||||
|
|
||||||
|
|
||||||
|
def _poll_response(
|
||||||
|
resolver: "PolicyResolver", source_ip: str, identity_token: str, proposal_id: str,
|
||||||
|
) -> "_sv.Response | None":
|
||||||
|
"""One non-blocking control-plane poll for `proposal_id`'s decision. Returns
|
||||||
|
the operator's Response once decided, or None while it is still pending
|
||||||
|
(or `unknown` — treated as still-waiting so a transient blip degrades to the
|
||||||
|
pending-timeout path rather than a spurious error mid grace window)."""
|
||||||
|
try:
|
||||||
|
result = resolver.poll_supervise(source_ip, identity_token, proposal_id)
|
||||||
|
except PolicyResolveError as e:
|
||||||
|
raise _RpcInternalError(f"orchestrator unreachable, cannot poll proposal: {e}") from e
|
||||||
|
if result is None:
|
||||||
|
raise _RpcInternalError("request source is not attributed to a bottle")
|
||||||
|
if result.get("status") in _sv.STATUSES:
|
||||||
|
return _response_from_poll(proposal_id, result)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _response_from_poll(proposal_id: str, result: "dict[str, object]") -> "_sv.Response":
|
||||||
|
"""Build a Response from a decided `/supervise/poll` payload."""
|
||||||
|
final_file = result.get("final_file")
|
||||||
|
return _sv.Response(
|
||||||
|
proposal_id=proposal_id,
|
||||||
|
status=str(result.get("status")),
|
||||||
|
notes=str(result.get("notes") or ""),
|
||||||
|
final_file=final_file if isinstance(final_file, str) else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def handle_check_proposal(
|
def handle_check_proposal(
|
||||||
params: dict[str, object],
|
params: dict[str, object],
|
||||||
config: ServerConfig,
|
*,
|
||||||
|
resolver: "PolicyResolver",
|
||||||
|
source_ip: str,
|
||||||
|
identity_token: str,
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
"""Non-blocking poll of a queued proposal's decision, by id.
|
"""Non-blocking poll of a queued proposal's decision, by id.
|
||||||
|
|
||||||
Never creates a Proposal (so `check-proposal` isn't in `TOOLS`); it only
|
Never creates a Proposal (so `check-proposal` isn't in `TOOLS`); it only
|
||||||
reads the queue. Resolution order mirrors the synchronous path's terminal
|
reads the queue over the control-plane RPC, attributed by
|
||||||
step — a decided proposal is archived here exactly as `handle_tools_call`
|
`(source_ip, identity_token)` so it can only read *this* bottle's own
|
||||||
archives it after `wait_for_response`, so `pending` proposals stay visible
|
proposals. The orchestrator archives a decided proposal on read — the same
|
||||||
to the operator until they're both decided *and* polled."""
|
terminal step the synchronous path triggers — so `pending` proposals stay
|
||||||
|
visible to the operator until they're both decided *and* polled."""
|
||||||
args_raw = params.get("arguments", {})
|
args_raw = params.get("arguments", {})
|
||||||
if not isinstance(args_raw, dict):
|
if not isinstance(args_raw, dict):
|
||||||
raise _RpcClientError(ERR_INVALID_PARAMS, "tools/call 'arguments' must be an object")
|
raise _RpcClientError(ERR_INVALID_PARAMS, "tools/call 'arguments' must be an object")
|
||||||
@@ -431,25 +486,24 @@ def handle_check_proposal(
|
|||||||
proposal_id = proposal_id.strip()
|
proposal_id = proposal_id.strip()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = _sv.read_response(config.bottle_slug, proposal_id)
|
result = resolver.poll_supervise(source_ip, identity_token, proposal_id)
|
||||||
except FileNotFoundError:
|
except PolicyResolveError as e:
|
||||||
# No decision yet — distinguish "still queued" from "unknown id".
|
raise _RpcInternalError(f"orchestrator unreachable, cannot poll proposal: {e}") from e
|
||||||
try:
|
if result is None:
|
||||||
_sv.read_proposal(config.bottle_slug, proposal_id)
|
raise _RpcInternalError("request source is not attributed to a bottle")
|
||||||
except FileNotFoundError:
|
status = result.get("status")
|
||||||
return {
|
|
||||||
"content": [{"type": "text", "text": format_unknown_proposal_text(proposal_id)}],
|
if status == _sv.POLL_STATUS_UNKNOWN:
|
||||||
"isError": True,
|
return {
|
||||||
}
|
"content": [{"type": "text", "text": format_unknown_proposal_text(proposal_id)}],
|
||||||
|
"isError": True,
|
||||||
|
}
|
||||||
|
if status == _sv.POLL_STATUS_PENDING:
|
||||||
return {
|
return {
|
||||||
"content": [{"type": "text", "text": format_still_pending_text(proposal_id)}],
|
"content": [{"type": "text", "text": format_still_pending_text(proposal_id)}],
|
||||||
"isError": False,
|
"isError": False,
|
||||||
}
|
}
|
||||||
|
response = _response_from_poll(proposal_id, result)
|
||||||
try:
|
|
||||||
_sv.archive_proposal(config.bottle_slug, proposal_id)
|
|
||||||
except OSError as e:
|
|
||||||
raise _RpcInternalError(f"failed to archive proposal: {e}") from e
|
|
||||||
return {
|
return {
|
||||||
"content": [{"type": "text", "text": format_response_text(response)}],
|
"content": [{"type": "text", "text": format_response_text(response)}],
|
||||||
"isError": response.status == _sv.STATUS_REJECTED,
|
"isError": response.status == _sv.STATUS_REJECTED,
|
||||||
@@ -591,16 +645,34 @@ 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()
|
||||||
|
resolver = self._resolver_or_fail()
|
||||||
|
source_ip = self.client_address[0]
|
||||||
|
token = self._identity_token()
|
||||||
# `check-proposal` is a non-blocking read of the calling bottle's
|
# `check-proposal` is a non-blocking read of the calling bottle's
|
||||||
# own queue — attributed by source IP like a proposal, but it
|
# own queue — attributed by (source_ip, identity_token) like a
|
||||||
# never queues or blocks.
|
# proposal, but it never queues or blocks.
|
||||||
if req.params.get("name") == _sv.TOOL_CHECK_PROPOSAL:
|
if req.params.get("name") == _sv.TOOL_CHECK_PROPOSAL:
|
||||||
return handle_check_proposal(req.params, self._attributed_config(config))
|
return handle_check_proposal(
|
||||||
# Attribute the proposal to the source-IP-resolved bottle, so the one
|
req.params, resolver=resolver,
|
||||||
# shared server queues each bottle's proposal under its own slug.
|
source_ip=source_ip, identity_token=token,
|
||||||
return handle_tools_call(req.params, self._attributed_config(config))
|
)
|
||||||
|
# The control plane attributes the proposal to the source-IP + token
|
||||||
|
# resolved bottle, so the one shared queue holds each bottle's
|
||||||
|
# proposal under its own id — no slug is asserted by this daemon.
|
||||||
|
return handle_tools_call(
|
||||||
|
req.params, config, resolver=resolver,
|
||||||
|
source_ip=source_ip, identity_token=token,
|
||||||
|
)
|
||||||
raise _RpcClientError(ERR_METHOD_NOT_FOUND, f"method not found: {method}")
|
raise _RpcClientError(ERR_METHOD_NOT_FOUND, f"method not found: {method}")
|
||||||
|
|
||||||
|
def _identity_token(self) -> str:
|
||||||
|
"""The agent's per-bottle identity token from the request header (the
|
||||||
|
MCP client sends it via `mcp add --header`), or empty. The control plane
|
||||||
|
requires the (source_ip, token) pair, so a missing/wrong token
|
||||||
|
fail-closes there."""
|
||||||
|
headers = getattr(self, "headers", None)
|
||||||
|
return headers.get(IDENTITY_HEADER, "") if headers is not None else ""
|
||||||
|
|
||||||
def _resolver_or_fail(self) -> "PolicyResolver":
|
def _resolver_or_fail(self) -> "PolicyResolver":
|
||||||
"""This server's policy resolver. A server started without one is a
|
"""This server's policy resolver. A server started without one is a
|
||||||
misconfiguration, not a tenancy mode — fail closed rather than
|
misconfiguration, not a tenancy mode — fail closed rather than
|
||||||
@@ -612,40 +684,18 @@ class MCPHandler(http.server.BaseHTTPRequestHandler):
|
|||||||
|
|
||||||
def _resolved_routes_payload(self) -> dict[str, object]:
|
def _resolved_routes_payload(self) -> dict[str, object]:
|
||||||
"""The calling bottle's live egress routes as the `list-egress-routes`
|
"""The calling bottle's live egress routes as the `list-egress-routes`
|
||||||
JSON payload, resolved by (source_ip, identity token). Fail-closed like
|
JSON payload, resolved by (source_ip, identity token). Fail-closed: an
|
||||||
`_attributed_config`: an unattributed source or an unreachable
|
unattributed source or an unreachable orchestrator yields an empty route
|
||||||
orchestrator yields an empty route list (never another bottle's),
|
list (never another bottle's), courtesy of `resolve_client_context`."""
|
||||||
courtesy of `resolve_client_context`."""
|
|
||||||
resolver = self._resolver_or_fail()
|
resolver = self._resolver_or_fail()
|
||||||
headers = getattr(self, "headers", None)
|
|
||||||
token = headers.get(IDENTITY_HEADER, "") if headers is not None else ""
|
|
||||||
conf, _slug, _tokens = resolve_client_context(
|
conf, _slug, _tokens = resolve_client_context(
|
||||||
resolver, self.client_address[0], token,
|
resolver, self.client_address[0], self._identity_token(),
|
||||||
)
|
)
|
||||||
body = json.dumps(
|
body = json.dumps(
|
||||||
{"routes": [route_to_yaml_dict(r) for r in conf.routes]}, indent=2,
|
{"routes": [route_to_yaml_dict(r) for r in conf.routes]}, indent=2,
|
||||||
)
|
)
|
||||||
return {"content": [{"type": "text", "text": body}], "isError": False}
|
return {"content": [{"type": "text", "text": body}], "isError": False}
|
||||||
|
|
||||||
def _attributed_config(self, config: ServerConfig) -> ServerConfig:
|
|
||||||
"""The ServerConfig with `bottle_slug` bound to *this request's* bottle:
|
|
||||||
the bottle id attributed from the source IP — **fail-closed**, an
|
|
||||||
unattributed or unreachable source raises so no proposal is queued under
|
|
||||||
the wrong (or empty) slug."""
|
|
||||||
resolver = self._resolver_or_fail()
|
|
||||||
# The agent's MCP client sends the identity token as a request header
|
|
||||||
# (provisioned via `mcp add --header`); the orchestrator requires the
|
|
||||||
# (source_ip, token) pair, so a missing/wrong token fail-closes below.
|
|
||||||
headers = getattr(self, "headers", None)
|
|
||||||
token = headers.get(IDENTITY_HEADER, "") if headers is not None else ""
|
|
||||||
try:
|
|
||||||
bottle_id = resolver.resolve_bottle_id(self.client_address[0], token)
|
|
||||||
except PolicyResolveError as e:
|
|
||||||
raise _RpcInternalError(f"orchestrator unreachable, cannot attribute: {e}") from e
|
|
||||||
if not bottle_id:
|
|
||||||
raise _RpcInternalError("request source is not attributed to a bottle")
|
|
||||||
return replace(config, bottle_slug=bottle_id)
|
|
||||||
|
|
||||||
def _write_jsonrpc(self, body: bytes) -> None:
|
def _write_jsonrpc(self, body: bytes) -> None:
|
||||||
self.send_response(200)
|
self.send_response(200)
|
||||||
self.send_header("Content-Type", "application/json")
|
self.send_header("Content-Type", "application/json")
|
||||||
@@ -668,10 +718,10 @@ class MCPHandler(http.server.BaseHTTPRequestHandler):
|
|||||||
class MCPServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
|
class MCPServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
|
||||||
allow_reuse_address = True
|
allow_reuse_address = True
|
||||||
daemon_threads = True
|
daemon_threads = True
|
||||||
config: ServerConfig = ServerConfig(bottle_slug="")
|
config: ServerConfig = ServerConfig()
|
||||||
# Set by `serve`; every proposal is attributed to the source-IP-resolved
|
# Set by `serve`; every proposal is attributed to the source-IP + token
|
||||||
# bottle. The class default is a placeholder — a server without a resolver
|
# resolved bottle by the control plane. A server without a resolver fails
|
||||||
# fails closed per request (see `_resolver_or_fail`).
|
# closed per request (see `_resolver_or_fail`).
|
||||||
policy_resolver: "PolicyResolver | None" = None
|
policy_resolver: "PolicyResolver | None" = None
|
||||||
|
|
||||||
|
|
||||||
@@ -686,12 +736,9 @@ def serve(
|
|||||||
response_timeout_seconds: float = DEFAULT_RESPONSE_TIMEOUT_SECONDS,
|
response_timeout_seconds: float = DEFAULT_RESPONSE_TIMEOUT_SECONDS,
|
||||||
) -> typing.NoReturn:
|
) -> typing.NoReturn:
|
||||||
server = MCPServer((bind, port), MCPHandler)
|
server = MCPServer((bind, port), MCPHandler)
|
||||||
# bottle_slug is a placeholder: every request's proposal is attributed to
|
# Every request's proposal is attributed to the source-IP + token resolved
|
||||||
# the source-IP-resolved bottle (see MCPHandler._attributed_config).
|
# bottle by the control plane (see MCPHandler._dispatch).
|
||||||
server.config = ServerConfig(
|
server.config = ServerConfig(response_timeout_seconds=response_timeout_seconds)
|
||||||
bottle_slug="",
|
|
||||||
response_timeout_seconds=response_timeout_seconds,
|
|
||||||
)
|
|
||||||
server.policy_resolver = resolver
|
server.policy_resolver = resolver
|
||||||
sys.stderr.write(
|
sys.stderr.write(
|
||||||
f"supervise listening on {bind}:{port}; multi-tenant; "
|
f"supervise listening on {bind}:{port}; multi-tenant; "
|
||||||
|
|||||||
@@ -37,6 +37,15 @@ STATUS_MODIFIED = "modified"
|
|||||||
STATUS_REJECTED = "rejected"
|
STATUS_REJECTED = "rejected"
|
||||||
STATUSES: tuple[str, ...] = (STATUS_APPROVED, STATUS_MODIFIED, STATUS_REJECTED)
|
STATUSES: tuple[str, ...] = (STATUS_APPROVED, STATUS_MODIFIED, STATUS_REJECTED)
|
||||||
|
|
||||||
|
# Non-terminal markers the control-plane supervise-poll RPC returns when a
|
||||||
|
# proposal has no operator decision yet (`PENDING`) or no queued proposal with
|
||||||
|
# that id exists for the calling bottle (`UNKNOWN`). They are never a
|
||||||
|
# `Response.status` — only the poll wire contract (see
|
||||||
|
# `Orchestrator.supervise_poll_response`) — so they are deliberately outside
|
||||||
|
# `STATUSES`.
|
||||||
|
POLL_STATUS_PENDING = "pending"
|
||||||
|
POLL_STATUS_UNKNOWN = "unknown"
|
||||||
|
|
||||||
ACTION_OPERATOR_EDIT = "operator-edit"
|
ACTION_OPERATOR_EDIT = "operator-edit"
|
||||||
|
|
||||||
|
|
||||||
@@ -157,6 +166,8 @@ __all__ = [
|
|||||||
"STATUS_APPROVED",
|
"STATUS_APPROVED",
|
||||||
"STATUS_MODIFIED",
|
"STATUS_MODIFIED",
|
||||||
"STATUS_REJECTED",
|
"STATUS_REJECTED",
|
||||||
|
"POLL_STATUS_PENDING",
|
||||||
|
"POLL_STATUS_UNKNOWN",
|
||||||
"TOOLS",
|
"TOOLS",
|
||||||
"TOOL_EGRESS_ALLOW",
|
"TOOL_EGRESS_ALLOW",
|
||||||
"TOOL_EGRESS_BLOCK",
|
"TOOL_EGRESS_BLOCK",
|
||||||
|
|||||||
@@ -299,11 +299,14 @@ and integrate a console against.
|
|||||||
the data plane and the console reach state through the control-plane RPC,
|
the data plane and the console reach state through the control-plane RPC,
|
||||||
never a direct file handle.** No agent-facing component gets the file, so
|
never a direct file handle.** No agent-facing component gets the file, so
|
||||||
none can forge attribution. (This supersedes the earlier `ro`-mount idea.)
|
none can forge attribution. (This supersedes the earlier `ro`-mount idea.)
|
||||||
- *Transitional caveat:* today the per-bottle **supervise gateway
|
This rule is now **in force** across the data plane (issue #469): the
|
||||||
rw-bind-mounts `bot-bottle.db`** to write proposals — exactly the
|
supervise daemon, the egress addon, and the git-gate pre-receive hook queue
|
||||||
pattern the orchestrator removes (supervise consolidates into the
|
proposals and poll for their responses over the agent-side supervise RPC
|
||||||
orchestrator; gateway writes become RPC calls). Until that lands, don't
|
(`POST /supervise/propose` + `POST /supervise/poll`, attributed by
|
||||||
put the attribution registry behind a data-plane-writable mount.
|
`(source_ip, identity_token)` exactly like `/resolve`), so a bottle can only
|
||||||
|
ever queue or read its own proposals. The gateway/data-plane containers no
|
||||||
|
longer bind-mount the DB directory or carry `SUPERVISE_DB_PATH`; the
|
||||||
|
orchestrator is the sole opener of the one file.
|
||||||
|
|
||||||
Implementation note for the VM slices: SQLite **WAL** over a guest share
|
Implementation note for the VM slices: SQLite **WAL** over a guest share
|
||||||
(virtiofs/9p) is finicky (the `-shm`/`-wal` files need real mmap/locking),
|
(virtiofs/9p) is finicky (the `-shm`/`-wal` files need real mmap/locking),
|
||||||
|
|||||||
@@ -209,6 +209,7 @@ from bot_bottle.egress_addon_core import ( # noqa: E402
|
|||||||
Route,
|
Route,
|
||||||
route_to_yaml_dict,
|
route_to_yaml_dict,
|
||||||
)
|
)
|
||||||
|
from bot_bottle.policy_resolver import PolicyResolveError # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -266,7 +267,42 @@ def _config_to_policy(config: Config) -> str:
|
|||||||
}) + "\n"
|
}) + "\n"
|
||||||
|
|
||||||
|
|
||||||
class _StaticResolver:
|
class _SuperviseRpcFake:
|
||||||
|
"""Mixin adding the control-plane supervise RPCs to a fake resolver, now
|
||||||
|
that the egress data plane queues/polls proposals over RPC instead of
|
||||||
|
opening the DB (issue #469). `supervise_status` is the operator's eventual
|
||||||
|
decision (None models a timeout — poll stays `pending`); `propose_error`
|
||||||
|
models an unreachable orchestrator. `propose_calls` records what was queued
|
||||||
|
so a test can assert the proposal was attributed by the caller's source IP."""
|
||||||
|
|
||||||
|
supervise_status: "str | None" = None
|
||||||
|
propose_error: bool = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def propose_calls(self) -> list:
|
||||||
|
if not hasattr(self, "_propose_calls"):
|
||||||
|
self._propose_calls: list = []
|
||||||
|
return self._propose_calls
|
||||||
|
|
||||||
|
def propose_supervise(
|
||||||
|
self, source_ip, identity_token, *, tool, proposed_file, justification,
|
||||||
|
):
|
||||||
|
del proposed_file, justification
|
||||||
|
self.propose_calls.append(
|
||||||
|
{"source_ip": source_ip, "identity_token": identity_token, "tool": tool}
|
||||||
|
)
|
||||||
|
if self.propose_error:
|
||||||
|
raise PolicyResolveError("orchestrator down")
|
||||||
|
return "prop-1"
|
||||||
|
|
||||||
|
def poll_supervise(self, source_ip, identity_token, proposal_id):
|
||||||
|
del source_ip, identity_token, proposal_id
|
||||||
|
if self.supervise_status is None:
|
||||||
|
return {"status": "pending"}
|
||||||
|
return {"status": self.supervise_status, "notes": "", "final_file": None}
|
||||||
|
|
||||||
|
|
||||||
|
class _StaticResolver(_SuperviseRpcFake):
|
||||||
"""Fake orchestrator resolver that serves one Config (+ optional bottle id
|
"""Fake orchestrator resolver that serves one Config (+ optional bottle id
|
||||||
and per-bottle tokens) for every client — the host-test stand-in for a
|
and per-bottle tokens) for every client — the host-test stand-in for a
|
||||||
bottle's policy now that egress is resolver-only."""
|
bottle's policy now that egress is resolver-only."""
|
||||||
@@ -323,7 +359,7 @@ def _with_client_ip(flow: _Flow, ip: str) -> _Flow:
|
|||||||
return flow
|
return flow
|
||||||
|
|
||||||
|
|
||||||
class _CtxResolver:
|
class _CtxResolver(_SuperviseRpcFake):
|
||||||
"""Fake orchestrator resolver: maps source IP -> bottle id, and grants the
|
"""Fake orchestrator resolver: maps source IP -> bottle id, and grants the
|
||||||
same allow-list to any attributed bottle (unattributed -> deny)."""
|
same allow-list to any attributed bottle (unattributed -> deny)."""
|
||||||
|
|
||||||
@@ -516,66 +552,33 @@ class TestOutboundDlpPolicy(unittest.TestCase):
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def _fake_sv(response_status: str | None) -> types.SimpleNamespace:
|
|
||||||
"""Stand-in for the `supervise` module the adapter queues proposals to.
|
|
||||||
|
|
||||||
`response_status` of None models a timeout (read_response never returns a
|
|
||||||
decision); a status string models the operator's eventual answer."""
|
|
||||||
def _new_proposal(**_kw: Any) -> Any:
|
|
||||||
return types.SimpleNamespace(id="prop-1")
|
|
||||||
|
|
||||||
def _sha256_hex(_payload: Any) -> str:
|
|
||||||
return "hash"
|
|
||||||
|
|
||||||
def _noop(*_args: Any) -> None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _read_response(_slug: Any, _pid: Any) -> Any:
|
|
||||||
if response_status is None:
|
|
||||||
raise OSError("not written yet") # forces poll -> timeout
|
|
||||||
return types.SimpleNamespace(status=response_status)
|
|
||||||
|
|
||||||
ns = types.SimpleNamespace()
|
|
||||||
ns.STATUS_APPROVED = "approved"
|
|
||||||
ns.STATUS_MODIFIED = "modified"
|
|
||||||
ns.TOOL_EGRESS_TOKEN_ALLOW = "egress_token_allow"
|
|
||||||
ns.Proposal = types.SimpleNamespace(new=_new_proposal)
|
|
||||||
ns.sha256_hex = _sha256_hex
|
|
||||||
ns.write_proposal = _noop
|
|
||||||
ns.archive_proposal = _noop
|
|
||||||
ns.read_response = _read_response
|
|
||||||
return ns
|
|
||||||
|
|
||||||
|
|
||||||
class TestSuperviseBranch(unittest.TestCase):
|
class TestSuperviseBranch(unittest.TestCase):
|
||||||
def _supervised_addon(self) -> EgressAddon:
|
def _supervised_addon(self, status: str | None) -> EgressAddon:
|
||||||
addon = _addon(Config(routes=(Route(host="api.example.com"),)), slug="test-bottle")
|
addon = _addon(Config(routes=(Route(host="api.example.com"),)), slug="test-bottle")
|
||||||
addon._token_allow_timeout = 0.05
|
addon._token_allow_timeout = 0.05
|
||||||
|
cast(Any, addon._resolver).supervise_status = status
|
||||||
return addon
|
return addon
|
||||||
|
|
||||||
def test_operator_approval_allows_token_and_forwards(self) -> None:
|
def test_operator_approval_allows_token_and_forwards(self) -> None:
|
||||||
addon = self._supervised_addon()
|
addon = self._supervised_addon("approved")
|
||||||
flow = _Flow(_Request(host="api.example.com", method="POST", body=f"k={_OPENAI_KEY}"))
|
flow = _Flow(_Request(host="api.example.com", method="POST", body=f"k={_OPENAI_KEY}"))
|
||||||
with patch.object(_ea_mod, "_sv", _fake_sv("approved")):
|
_run_request(addon, flow)
|
||||||
_run_request(addon, flow)
|
|
||||||
self.assertIsNone(flow.response) # forwarded after approval
|
self.assertIsNone(flow.response) # forwarded after approval
|
||||||
# Approval lands in the calling bottle's safelist (keyed by slug).
|
# Approval lands in the calling bottle's safelist (keyed by slug).
|
||||||
self.assertIn(_OPENAI_KEY, addon._safe_tokens_for("test-bottle"))
|
self.assertIn(_OPENAI_KEY, addon._safe_tokens_for("test-bottle"))
|
||||||
|
|
||||||
def test_operator_rejection_blocks(self) -> None:
|
def test_operator_rejection_blocks(self) -> None:
|
||||||
addon = self._supervised_addon()
|
addon = self._supervised_addon("rejected")
|
||||||
flow = _Flow(_Request(host="api.example.com", method="POST", body=f"k={_OPENAI_KEY}"))
|
flow = _Flow(_Request(host="api.example.com", method="POST", body=f"k={_OPENAI_KEY}"))
|
||||||
with patch.object(_ea_mod, "_sv", _fake_sv("rejected")):
|
_run_request(addon, flow)
|
||||||
_run_request(addon, flow)
|
|
||||||
assert flow.response is not None
|
assert flow.response is not None
|
||||||
self.assertEqual(403, flow.response.status_code)
|
self.assertEqual(403, flow.response.status_code)
|
||||||
self.assertIn("rejected", flow.response.get_text())
|
self.assertIn("rejected", flow.response.get_text())
|
||||||
|
|
||||||
def test_supervise_timeout_blocks(self) -> None:
|
def test_supervise_timeout_blocks(self) -> None:
|
||||||
addon = self._supervised_addon()
|
addon = self._supervised_addon(None) # poll stays pending -> timeout
|
||||||
flow = _Flow(_Request(host="api.example.com", method="POST", body=f"k={_OPENAI_KEY}"))
|
flow = _Flow(_Request(host="api.example.com", method="POST", body=f"k={_OPENAI_KEY}"))
|
||||||
with patch.object(_ea_mod, "_sv", _fake_sv(None)):
|
_run_request(addon, flow)
|
||||||
_run_request(addon, flow)
|
|
||||||
assert flow.response is not None
|
assert flow.response is not None
|
||||||
self.assertEqual(403, flow.response.status_code)
|
self.assertEqual(403, flow.response.status_code)
|
||||||
self.assertIn("timed out", flow.response.get_text())
|
self.assertIn("timed out", flow.response.get_text())
|
||||||
@@ -772,19 +775,14 @@ class TestRedactSurfaces(unittest.TestCase):
|
|||||||
|
|
||||||
|
|
||||||
class TestSuperviseWriteFailure(unittest.TestCase):
|
class TestSuperviseWriteFailure(unittest.TestCase):
|
||||||
def test_write_proposal_oserror_blocks(self) -> None:
|
def test_propose_rpc_error_blocks(self) -> None:
|
||||||
|
# An unreachable orchestrator (propose RPC raises) fails closed: the
|
||||||
|
# request is blocked rather than forwarded unsupervised.
|
||||||
addon = _addon(Config(routes=(Route(host="api.example.com"),)), slug="test-bottle")
|
addon = _addon(Config(routes=(Route(host="api.example.com"),)), slug="test-bottle")
|
||||||
addon._token_allow_timeout = 0.05
|
addon._token_allow_timeout = 0.05
|
||||||
|
cast(Any, addon._resolver).propose_error = True
|
||||||
flow = _Flow(_Request(host="api.example.com", method="POST", body=f"k={_OPENAI_KEY}"))
|
flow = _Flow(_Request(host="api.example.com", method="POST", body=f"k={_OPENAI_KEY}"))
|
||||||
|
_run_request(addon, flow)
|
||||||
fake = _fake_sv("approved")
|
|
||||||
|
|
||||||
def _raise(_p: Any) -> None:
|
|
||||||
raise OSError("disk full")
|
|
||||||
|
|
||||||
fake.write_proposal = _raise
|
|
||||||
with patch.object(_ea_mod, "_sv", fake):
|
|
||||||
_run_request(addon, flow)
|
|
||||||
assert flow.response is not None
|
assert flow.response is not None
|
||||||
self.assertEqual(403, flow.response.status_code)
|
self.assertEqual(403, flow.response.status_code)
|
||||||
|
|
||||||
@@ -844,22 +842,23 @@ class TestSuperviseMultiTenant(unittest.TestCase):
|
|||||||
"""Consolidated gateway: supervise proposals + the DLP safelist are keyed
|
"""Consolidated gateway: supervise proposals + the DLP safelist are keyed
|
||||||
per bottle, resolved by source IP (PRD 0070)."""
|
per bottle, resolved by source IP (PRD 0070)."""
|
||||||
|
|
||||||
def _consolidated_addon(self) -> EgressAddon:
|
def _consolidated_addon(self, status: str | None = "approved") -> EgressAddon:
|
||||||
# Static config is empty; the resolver supplies each bottle's config.
|
# Static config is empty; the resolver supplies each bottle's config.
|
||||||
addon = _addon(Config(routes=()))
|
addon = _addon(Config(routes=()))
|
||||||
addon._resolver = cast(Any, _CtxResolver({"10.0.0.1": "bottle-a", "10.0.0.2": "bottle-b"}))
|
resolver = _CtxResolver({"10.0.0.1": "bottle-a", "10.0.0.2": "bottle-b"})
|
||||||
|
resolver.supervise_status = status
|
||||||
|
addon._resolver = cast(Any, resolver)
|
||||||
addon._token_allow_timeout = 0.05
|
addon._token_allow_timeout = 0.05
|
||||||
return addon
|
return addon
|
||||||
|
|
||||||
def test_approval_is_scoped_to_the_calling_bottle(self) -> None:
|
def test_approval_is_scoped_to_the_calling_bottle(self) -> None:
|
||||||
addon = self._consolidated_addon()
|
addon = self._consolidated_addon("approved")
|
||||||
# bottle-a (10.0.0.1) sends the token; the operator approves.
|
# bottle-a (10.0.0.1) sends the token; the operator approves.
|
||||||
flow = _with_client_ip(
|
flow = _with_client_ip(
|
||||||
_Flow(_Request(host="api.example.com", method="POST", body=f"k={_OPENAI_KEY}")),
|
_Flow(_Request(host="api.example.com", method="POST", body=f"k={_OPENAI_KEY}")),
|
||||||
"10.0.0.1",
|
"10.0.0.1",
|
||||||
)
|
)
|
||||||
with patch.object(_ea_mod, "_sv", _fake_sv("approved")):
|
_run_request(addon, flow)
|
||||||
_run_request(addon, flow)
|
|
||||||
self.assertIsNone(flow.response) # forwarded after approval
|
self.assertIsNone(flow.response) # forwarded after approval
|
||||||
# The approval lands ONLY in bottle-a's safelist — never bottle-b's.
|
# The approval lands ONLY in bottle-a's safelist — never bottle-b's.
|
||||||
# A global set here would be the cross-tenant leak this slice closes.
|
# A global set here would be the cross-tenant leak this slice closes.
|
||||||
@@ -867,22 +866,19 @@ class TestSuperviseMultiTenant(unittest.TestCase):
|
|||||||
self.assertNotIn(_OPENAI_KEY, addon._safe_tokens_for("bottle-b"))
|
self.assertNotIn(_OPENAI_KEY, addon._safe_tokens_for("bottle-b"))
|
||||||
|
|
||||||
def test_proposal_is_attributed_to_the_source_ip_bottle(self) -> None:
|
def test_proposal_is_attributed_to_the_source_ip_bottle(self) -> None:
|
||||||
addon = self._consolidated_addon()
|
addon = self._consolidated_addon("approved")
|
||||||
seen: list[str] = []
|
|
||||||
fake = _fake_sv("approved")
|
|
||||||
|
|
||||||
def _capture(**kw: Any) -> Any:
|
|
||||||
seen.append(kw["bottle_slug"])
|
|
||||||
return types.SimpleNamespace(id="p")
|
|
||||||
|
|
||||||
fake.Proposal = types.SimpleNamespace(new=_capture)
|
|
||||||
flow = _with_client_ip(
|
flow = _with_client_ip(
|
||||||
_Flow(_Request(host="api.example.com", method="POST", body=f"k={_OPENAI_KEY}")),
|
_Flow(_Request(host="api.example.com", method="POST", body=f"k={_OPENAI_KEY}")),
|
||||||
"10.0.0.2",
|
"10.0.0.2",
|
||||||
)
|
)
|
||||||
with patch.object(_ea_mod, "_sv", fake):
|
_run_request(addon, flow)
|
||||||
_run_request(addon, flow)
|
# The addon forwards the caller's source IP to the control plane, which
|
||||||
self.assertEqual(["bottle-b"], seen) # proposal keyed by the resolved bottle
|
# attributes the proposal server-side by (source_ip, identity_token) —
|
||||||
|
# the addon never asserts a slug. The resolved bottle keys the safelist.
|
||||||
|
calls = cast(Any, addon._resolver).propose_calls
|
||||||
|
self.assertEqual(["10.0.0.2"], [c["source_ip"] for c in calls])
|
||||||
|
self.assertIn(_OPENAI_KEY, addon._safe_tokens_for("bottle-b"))
|
||||||
|
self.assertNotIn(_OPENAI_KEY, addon._safe_tokens_for("bottle-a"))
|
||||||
|
|
||||||
def test_auth_token_injected_from_resolved_tokens(self) -> None:
|
def test_auth_token_injected_from_resolved_tokens(self) -> None:
|
||||||
# The bottle's upstream token comes from /resolve (in-memory on the
|
# The bottle's upstream token comes from /resolve (in-memory on the
|
||||||
@@ -925,16 +921,17 @@ class TestSuperviseMultiTenant(unittest.TestCase):
|
|||||||
self.assertIsNotNone(flow.response) # blocked — token unset
|
self.assertIsNotNone(flow.response) # blocked — token unset
|
||||||
|
|
||||||
def test_unattributed_source_ip_cannot_supervise(self) -> None:
|
def test_unattributed_source_ip_cannot_supervise(self) -> None:
|
||||||
addon = self._consolidated_addon()
|
addon = self._consolidated_addon("approved")
|
||||||
# 10.9.9.9 is not in the resolver map -> deny-all config, empty slug.
|
# 10.9.9.9 is not in the resolver map -> deny-all config, empty slug.
|
||||||
flow = _with_client_ip(
|
flow = _with_client_ip(
|
||||||
_Flow(_Request(host="api.example.com", method="POST", body=f"k={_OPENAI_KEY}")),
|
_Flow(_Request(host="api.example.com", method="POST", body=f"k={_OPENAI_KEY}")),
|
||||||
"10.9.9.9",
|
"10.9.9.9",
|
||||||
)
|
)
|
||||||
with patch.object(_ea_mod, "_sv", _fake_sv("approved")):
|
_run_request(addon, flow)
|
||||||
_run_request(addon, flow)
|
|
||||||
self.assertIsNotNone(flow.response) # blocked (no route, no supervise)
|
self.assertIsNotNone(flow.response) # blocked (no route, no supervise)
|
||||||
self.assertNotIn(_OPENAI_KEY, addon._safe_tokens_for(""))
|
self.assertNotIn(_OPENAI_KEY, addon._safe_tokens_for(""))
|
||||||
|
# Never even reached the queue — no route means no supervise proposal.
|
||||||
|
self.assertEqual([], cast(Any, addon._resolver).propose_calls)
|
||||||
|
|
||||||
|
|
||||||
class TestMultiTenantInboundDlp(unittest.TestCase):
|
class TestMultiTenantInboundDlp(unittest.TestCase):
|
||||||
|
|||||||
+13
-10
@@ -213,22 +213,25 @@ class TestHookRender(unittest.TestCase):
|
|||||||
# the suppressed findings for human approval.
|
# the suppressed findings for human approval.
|
||||||
self.assertIn("--ignore-gitleaks-allow", hook)
|
self.assertIn("--ignore-gitleaks-allow", hook)
|
||||||
self.assertIn("--report-format=json", hook)
|
self.assertIn("--report-format=json", hook)
|
||||||
self.assertIn("tool=_sv.TOOL_GITLEAKS_ALLOW", hook)
|
# The hook queues + polls over the control-plane RPC — it no longer
|
||||||
self.assertIn("_sv.write_proposal", hook)
|
# opens the DB directly (PRD 0070 / issue #469).
|
||||||
self.assertIn("_sv.read_response", hook)
|
self.assertIn("tool=TOOL_GITLEAKS_ALLOW", hook)
|
||||||
self.assertIn("SUPERVISE_BOTTLE_SLUG", hook)
|
self.assertIn("propose_supervise", hook)
|
||||||
|
self.assertIn("poll_supervise", hook)
|
||||||
|
self.assertIn("SUPERVISE_SOURCE_IP", hook)
|
||||||
|
self.assertIn("SUPERVISE_IDENTITY_TOKEN", hook)
|
||||||
self.assertIn("supervisor approved # gitleaks:allow", hook)
|
self.assertIn("supervisor approved # gitleaks:allow", hook)
|
||||||
self.assertIn("supervisor rejected # gitleaks:allow", hook)
|
self.assertIn("supervisor rejected # gitleaks:allow", hook)
|
||||||
|
|
||||||
def test_inline_gitleaks_allow_python_imports_work_in_gateway_layout(self):
|
def test_inline_gitleaks_allow_python_imports_work_in_gateway_layout(self):
|
||||||
hook = git_gate_render_hook()
|
hook = git_gate_render_hook()
|
||||||
# The gateway image copies supervise.py flat under /app, while
|
# The gateway image copies the package modules flat under /app, while
|
||||||
# host-side tests import it through the bot_bottle package.
|
# host-side tests import them through the bot_bottle package. Hooks
|
||||||
# Hooks execute from the bare repo directory, so the embedded
|
# execute from the bare repo directory, so the embedded Python must
|
||||||
# Python must include /app and support both import layouts.
|
# include /app and support both import layouts.
|
||||||
self.assertIn('PYTHONPATH="/app${PYTHONPATH:+:$PYTHONPATH}"', hook)
|
self.assertIn('PYTHONPATH="/app${PYTHONPATH:+:$PYTHONPATH}"', hook)
|
||||||
self.assertIn("import supervise as _sv", hook)
|
self.assertIn("from bot_bottle.policy_resolver import PolicyResolver", hook)
|
||||||
self.assertIn("from bot_bottle import supervise as _sv", hook)
|
self.assertIn("from policy_resolver import PolicyResolver", hook)
|
||||||
|
|
||||||
def test_inline_gitleaks_allow_fails_closed_without_supervisor(self):
|
def test_inline_gitleaks_allow_fails_closed_without_supervisor(self):
|
||||||
hook = git_gate_render_hook()
|
hook = git_gate_render_hook()
|
||||||
|
|||||||
@@ -112,11 +112,13 @@ class TestGitHttpBackend(unittest.TestCase):
|
|||||||
).strip()
|
).strip()
|
||||||
self.assertEqual(head, cloned)
|
self.assertEqual(head, cloned)
|
||||||
|
|
||||||
def test_consolidated_push_stamps_bottle_slug_for_the_hook(self):
|
def test_consolidated_push_stamps_supervise_attribution_for_the_hook(self):
|
||||||
# In consolidated mode the backend attributes the push by source IP and
|
# In consolidated mode the backend attributes the push by (source IP,
|
||||||
# stamps SUPERVISE_BOTTLE_SLUG=<bottle_id> into the CGI env, so the
|
# identity token) and stamps SUPERVISE_SOURCE_IP + SUPERVISE_IDENTITY_TOKEN
|
||||||
# gitleaks-allow pre-receive hook queues its proposal under the right
|
# into the CGI env, so the gitleaks-allow pre-receive hook can queue its
|
||||||
# bottle. The hook here just records what it received.
|
# proposal over the control-plane RPC (which re-resolves the bottle from
|
||||||
|
# exactly that pair — PRD 0070 / issue #469). The hook here just records
|
||||||
|
# the source IP it received.
|
||||||
from http.server import ThreadingHTTPServer
|
from http.server import ThreadingHTTPServer
|
||||||
|
|
||||||
bottle_id = "bottleab12"
|
bottle_id = "bottleab12"
|
||||||
@@ -130,10 +132,10 @@ class TestGitHttpBackend(unittest.TestCase):
|
|||||||
["git", "-C", str(bare), "config", "http.receivepack", "true"],
|
["git", "-C", str(bare), "config", "http.receivepack", "true"],
|
||||||
check=True,
|
check=True,
|
||||||
)
|
)
|
||||||
capture = root / "slug-capture"
|
capture = root / "source-ip-capture"
|
||||||
hook = bare / "hooks" / "pre-receive"
|
hook = bare / "hooks" / "pre-receive"
|
||||||
hook.write_text(
|
hook.write_text(
|
||||||
f"#!/bin/sh\nprintf '%s' \"${{SUPERVISE_BOTTLE_SLUG:-UNSET}}\" > "
|
f"#!/bin/sh\nprintf '%s' \"${{SUPERVISE_SOURCE_IP:-UNSET}}\" > "
|
||||||
f"{capture}\ncat >/dev/null\nexit 0\n"
|
f"{capture}\ncat >/dev/null\nexit 0\n"
|
||||||
)
|
)
|
||||||
hook.chmod(0o755)
|
hook.chmod(0o755)
|
||||||
@@ -166,7 +168,7 @@ class TestGitHttpBackend(unittest.TestCase):
|
|||||||
["git", "push", url, "HEAD:refs/heads/main"],
|
["git", "push", url, "HEAD:refs/heads/main"],
|
||||||
cwd=work, check=True, capture_output=True, text=True, timeout=5,
|
cwd=work, check=True, capture_output=True, text=True, timeout=5,
|
||||||
)
|
)
|
||||||
self.assertEqual(bottle_id, capture.read_text())
|
self.assertEqual("127.0.0.1", capture.read_text())
|
||||||
|
|
||||||
def test_post_forwards_git_cgi_headers(self):
|
def test_post_forwards_git_cgi_headers(self):
|
||||||
from http.server import ThreadingHTTPServer
|
from http.server import ThreadingHTTPServer
|
||||||
|
|||||||
@@ -426,6 +426,112 @@ class TestDispatchSupervise(unittest.TestCase):
|
|||||||
self.assertIn("no such proposal", str(payload["error"]))
|
self.assertIn("no such proposal", str(payload["error"]))
|
||||||
|
|
||||||
|
|
||||||
|
class TestDispatchSuperviseAgentRpc(unittest.TestCase):
|
||||||
|
"""The agent half — `/supervise/propose` + `/supervise/poll` — attributed by
|
||||||
|
(source_ip, identity_token) like /resolve, so a bottle can only ever queue
|
||||||
|
or read its own proposals (PRD 0070 / issue #469)."""
|
||||||
|
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self._tmp = tempfile.TemporaryDirectory()
|
||||||
|
root = Path(self._tmp.name)
|
||||||
|
db = root / "db" / "bot-bottle.db"
|
||||||
|
db.parent.mkdir(parents=True)
|
||||||
|
self._env = patch.dict("os.environ", {
|
||||||
|
"BOT_BOTTLE_ROOT": str(root),
|
||||||
|
"SUPERVISE_DB_PATH": str(db),
|
||||||
|
})
|
||||||
|
self._env.start()
|
||||||
|
self.store = RegistryStore(db)
|
||||||
|
self.store.migrate()
|
||||||
|
StoreManager(db).migrate()
|
||||||
|
secret = secrets.token_bytes(16)
|
||||||
|
self.orch = Orchestrator(self.store, StubBroker(secret), secret)
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self._env.stop()
|
||||||
|
self._tmp.cleanup()
|
||||||
|
|
||||||
|
def _register(self, source_ip: str = "10.243.0.9", slug: str = "demo"):
|
||||||
|
return self.store.register(
|
||||||
|
source_ip, metadata=json.dumps({"slug": slug}), policy="routes: []\n")
|
||||||
|
|
||||||
|
def _propose(self, rec, proposed: str = "routes:\n - host: g.com\n"):
|
||||||
|
return dispatch(self.orch, "POST", "/supervise/propose", _body({
|
||||||
|
"source_ip": rec.source_ip, "identity_token": rec.identity_token,
|
||||||
|
"tool": TOOL_EGRESS_ALLOW, "proposed_file": proposed, "justification": "need it",
|
||||||
|
}))
|
||||||
|
|
||||||
|
def _poll(self, rec, proposal_id: str):
|
||||||
|
return dispatch(self.orch, "POST", "/supervise/poll", _body({
|
||||||
|
"source_ip": rec.source_ip, "identity_token": rec.identity_token,
|
||||||
|
"proposal_id": proposal_id,
|
||||||
|
}))
|
||||||
|
|
||||||
|
def test_propose_queues_under_the_resolved_bottle(self) -> None:
|
||||||
|
rec = self._register()
|
||||||
|
status, payload = self._propose(rec)
|
||||||
|
self.assertEqual(201, status)
|
||||||
|
pid = payload["proposal_id"]
|
||||||
|
assert isinstance(pid, str) and pid
|
||||||
|
_, listing = dispatch(self.orch, "GET", "/supervise/proposals", b"")
|
||||||
|
self.assertEqual(pid, listing["proposals"][0]["id"])
|
||||||
|
# Queued under the orchestrator-resolved bottle id, never a caller slug.
|
||||||
|
self.assertEqual(rec.bottle_id, listing["proposals"][0]["bottle_slug"])
|
||||||
|
|
||||||
|
def test_propose_unattributed_is_403(self) -> None:
|
||||||
|
status, payload = dispatch(self.orch, "POST", "/supervise/propose", _body({
|
||||||
|
"source_ip": "10.9.9.9", "identity_token": "wrong",
|
||||||
|
"tool": TOOL_EGRESS_ALLOW, "proposed_file": "x\n", "justification": "j",
|
||||||
|
}))
|
||||||
|
self.assertEqual(403, status)
|
||||||
|
self.assertIn("unattributed", str(payload["error"]))
|
||||||
|
|
||||||
|
def test_propose_rejects_unknown_tool(self) -> None:
|
||||||
|
rec = self._register()
|
||||||
|
status, _ = dispatch(self.orch, "POST", "/supervise/propose", _body({
|
||||||
|
"source_ip": rec.source_ip, "identity_token": rec.identity_token,
|
||||||
|
"tool": "not-a-tool", "proposed_file": "x\n", "justification": "j",
|
||||||
|
}))
|
||||||
|
self.assertEqual(400, status)
|
||||||
|
|
||||||
|
def test_poll_pending_then_decided_then_archived(self) -> None:
|
||||||
|
rec = self._register()
|
||||||
|
_, proposed = self._propose(rec)
|
||||||
|
pid = proposed["proposal_id"]
|
||||||
|
assert isinstance(pid, str)
|
||||||
|
|
||||||
|
status, poll = self._poll(rec, pid)
|
||||||
|
self.assertEqual(200, status)
|
||||||
|
self.assertEqual("pending", poll["status"])
|
||||||
|
|
||||||
|
# Operator decides server-side.
|
||||||
|
dispatch(self.orch, "POST", "/supervise/respond", _body({
|
||||||
|
"proposal_id": pid, "bottle_slug": rec.bottle_id,
|
||||||
|
"decision": "approve", "notes": "ok",
|
||||||
|
}))
|
||||||
|
_, decided = self._poll(rec, pid)
|
||||||
|
self.assertEqual("approved", decided["status"])
|
||||||
|
self.assertEqual("ok", decided["notes"])
|
||||||
|
|
||||||
|
# The decided poll archived it: gone from pending, and a re-poll is
|
||||||
|
# 'unknown' rather than replaying the decision forever.
|
||||||
|
_, listing = dispatch(self.orch, "GET", "/supervise/proposals", b"")
|
||||||
|
self.assertEqual([], listing["proposals"])
|
||||||
|
_, again = self._poll(rec, pid)
|
||||||
|
self.assertEqual("unknown", again["status"])
|
||||||
|
|
||||||
|
def test_poll_cannot_read_another_bottles_proposal(self) -> None:
|
||||||
|
rec_a = self._register("10.0.0.1", "a")
|
||||||
|
rec_b = self._register("10.0.0.2", "b")
|
||||||
|
_, proposed = self._propose(rec_a)
|
||||||
|
pid = proposed["proposal_id"]
|
||||||
|
assert isinstance(pid, str)
|
||||||
|
# b polls a's proposal id: scoped to b's own queue → never a's response.
|
||||||
|
status, poll = self._poll(rec_b, pid)
|
||||||
|
self.assertEqual(200, status)
|
||||||
|
self.assertEqual("unknown", poll["status"])
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
||||||
|
|||||||
@@ -124,13 +124,11 @@ class TestDockerGateway(unittest.TestCase):
|
|||||||
src = ca_mounts[0].rsplit(":", 1)[0]
|
src = ca_mounts[0].rsplit(":", 1)[0]
|
||||||
self.assertTrue(src.endswith("/" + GATEWAY_CA_DIRNAME), src)
|
self.assertTrue(src.endswith("/" + GATEWAY_CA_DIRNAME), src)
|
||||||
self.assertTrue(Path(src).is_absolute(), src)
|
self.assertTrue(Path(src).is_absolute(), src)
|
||||||
# Shares the ONE host DB: the supervise daemon queues into the same
|
# No DB handle on the data plane: the supervise queue is reached over
|
||||||
# file the orchestrator + operator (over HTTP) use.
|
# the control-plane RPC, so the gateway container carries neither the
|
||||||
self.assertTrue(any(
|
# DB bind-mount nor SUPERVISE_DB_PATH (PRD 0070 / issue #469).
|
||||||
a.startswith("SUPERVISE_DB_PATH=") and a.endswith("/run/supervise/bot-bottle.db")
|
self.assertFalse(any(a.startswith("SUPERVISE_DB_PATH=") for a in runs[0]))
|
||||||
for a in runs[0]))
|
self.assertFalse(any(a.endswith(":/run/supervise") for a in runs[0]))
|
||||||
self.assertTrue(any(
|
|
||||||
a.endswith(":/run/supervise") for a in runs[0]))
|
|
||||||
# Data plane resolves policy against the orchestrator control plane.
|
# Data plane resolves policy against the orchestrator control plane.
|
||||||
self.assertIn(f"BOT_BOTTLE_ORCHESTRATOR_URL={_ORCH_URL}", runs[0])
|
self.assertIn(f"BOT_BOTTLE_ORCHESTRATOR_URL={_ORCH_URL}", runs[0])
|
||||||
|
|
||||||
|
|||||||
@@ -323,6 +323,43 @@ class TestOrchestratorSupervise(unittest.TestCase):
|
|||||||
self.assertFalse(ok)
|
self.assertFalse(ok)
|
||||||
self.assertIn("no longer registered", err)
|
self.assertIn("no longer registered", err)
|
||||||
|
|
||||||
|
# --- agent half: queue + poll (issue #469) -----------------------------
|
||||||
|
|
||||||
|
def test_queue_proposal_then_poll_pending(self) -> None:
|
||||||
|
bottle_id = self._register("demo", "routes: []\n")
|
||||||
|
pid = self.orch.supervise_queue_proposal(
|
||||||
|
bottle_id, tool=TOOL_EGRESS_ALLOW,
|
||||||
|
proposed_file="routes:\n - host: google.com\n", justification="need it")
|
||||||
|
# Visible to the operator, keyed by the bottle id.
|
||||||
|
pending = self.orch.supervise_pending()
|
||||||
|
self.assertEqual([pid], [p["id"] for p in pending])
|
||||||
|
self.assertEqual(
|
||||||
|
{"status": "pending"}, self.orch.supervise_poll_response(bottle_id, pid))
|
||||||
|
|
||||||
|
def test_poll_returns_decision_and_archives(self) -> None:
|
||||||
|
bottle_id = self._register("demo", "routes: []\n")
|
||||||
|
pid = self.orch.supervise_queue_proposal(
|
||||||
|
bottle_id, tool=TOOL_EGRESS_ALLOW,
|
||||||
|
proposed_file="routes:\n - host: google.com\n", justification="need it")
|
||||||
|
self.orch.supervise_respond(
|
||||||
|
pid, bottle_slug=bottle_id, decision="approve", notes="ok")
|
||||||
|
decided = self.orch.supervise_poll_response(bottle_id, pid)
|
||||||
|
self.assertEqual("approved", decided["status"])
|
||||||
|
self.assertEqual("ok", decided["notes"])
|
||||||
|
# Archived on read → gone from pending, and a re-poll is 'unknown'.
|
||||||
|
self.assertEqual([], self.orch.supervise_pending())
|
||||||
|
self.assertEqual(
|
||||||
|
{"status": "unknown"}, self.orch.supervise_poll_response(bottle_id, pid))
|
||||||
|
|
||||||
|
def test_poll_unknown_for_other_bottle(self) -> None:
|
||||||
|
bottle_id = self._register("demo", "routes: []\n")
|
||||||
|
pid = self.orch.supervise_queue_proposal(
|
||||||
|
bottle_id, tool=TOOL_EGRESS_ALLOW,
|
||||||
|
proposed_file="routes:\n - host: google.com\n", justification="j")
|
||||||
|
# A different bottle id can't read demo's proposal (scoped by queue key).
|
||||||
|
self.assertEqual(
|
||||||
|
{"status": "unknown"}, self.orch.supervise_poll_response("other-bottle", pid))
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -106,6 +106,58 @@ class TestPolicyResolver(unittest.TestCase):
|
|||||||
with self.assertRaises(PolicyResolveError):
|
with self.assertRaises(PolicyResolveError):
|
||||||
self.r.resolve_policy_and_bottle_id("10.243.0.1")
|
self.r.resolve_policy_and_bottle_id("10.243.0.1")
|
||||||
|
|
||||||
|
# --- supervise agent RPCs (issue #469) ---------------------------------
|
||||||
|
|
||||||
|
def test_propose_supervise_returns_id_and_posts_payload(self) -> None:
|
||||||
|
with patch(_URLOPEN, return_value=_resp({"proposal_id": "p-7"})) as m:
|
||||||
|
pid = self.r.propose_supervise(
|
||||||
|
"10.243.0.7", "the-token",
|
||||||
|
tool="egress-allow", proposed_file="routes:\n", justification="j",
|
||||||
|
)
|
||||||
|
self.assertEqual("p-7", pid)
|
||||||
|
req = m.call_args.args[0]
|
||||||
|
self.assertTrue(req.full_url.endswith("/supervise/propose"))
|
||||||
|
sent = json.loads(req.data)
|
||||||
|
self.assertEqual("10.243.0.7", sent["source_ip"])
|
||||||
|
self.assertEqual("the-token", sent["identity_token"])
|
||||||
|
self.assertEqual("egress-allow", sent["tool"])
|
||||||
|
self.assertEqual("routes:\n", sent["proposed_file"])
|
||||||
|
|
||||||
|
def test_propose_supervise_unattributed_is_none(self) -> None:
|
||||||
|
with patch(_URLOPEN, side_effect=_http_error(403)):
|
||||||
|
self.assertIsNone(self.r.propose_supervise(
|
||||||
|
"10.9.9.9", "t", tool="egress-allow", proposed_file="x", justification="j"))
|
||||||
|
|
||||||
|
def test_propose_supervise_missing_id_is_none(self) -> None:
|
||||||
|
with patch(_URLOPEN, return_value=_resp({})):
|
||||||
|
self.assertIsNone(self.r.propose_supervise(
|
||||||
|
"10.243.0.1", "t", tool="egress-allow", proposed_file="x", justification="j"))
|
||||||
|
|
||||||
|
def test_propose_supervise_unreachable_raises(self) -> None:
|
||||||
|
with patch(_URLOPEN, side_effect=urllib.error.URLError("refused")):
|
||||||
|
with self.assertRaises(PolicyResolveError):
|
||||||
|
self.r.propose_supervise(
|
||||||
|
"10.243.0.1", "t", tool="egress-allow", proposed_file="x", justification="j")
|
||||||
|
|
||||||
|
def test_poll_supervise_returns_status(self) -> None:
|
||||||
|
with patch(_URLOPEN, return_value=_resp(
|
||||||
|
{"status": "approved", "notes": "ok", "final_file": None})
|
||||||
|
) as m:
|
||||||
|
result = self.r.poll_supervise("10.243.0.7", "tok", "p-7")
|
||||||
|
self.assertEqual("approved", result["status"])
|
||||||
|
req = m.call_args.args[0]
|
||||||
|
self.assertTrue(req.full_url.endswith("/supervise/poll"))
|
||||||
|
self.assertEqual("p-7", json.loads(req.data)["proposal_id"])
|
||||||
|
|
||||||
|
def test_poll_supervise_unattributed_is_none(self) -> None:
|
||||||
|
with patch(_URLOPEN, side_effect=_http_error(403)):
|
||||||
|
self.assertIsNone(self.r.poll_supervise("10.9.9.9", "t", "p-7"))
|
||||||
|
|
||||||
|
def test_poll_supervise_unreachable_raises(self) -> None:
|
||||||
|
with patch(_URLOPEN, side_effect=urllib.error.URLError("refused")):
|
||||||
|
with self.assertRaises(PolicyResolveError):
|
||||||
|
self.r.poll_supervise("10.243.0.1", "t", "p-7")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
+188
-189
@@ -1,4 +1,11 @@
|
|||||||
"""Unit: supervise daemon MCP server (PRD 0013)."""
|
"""Unit: supervise daemon MCP server (PRD 0013, PRD 0070).
|
||||||
|
|
||||||
|
The daemon no longer opens bot-bottle.db: it queues proposals and polls for
|
||||||
|
their responses over the control-plane RPC (issue #469). These tests drive the
|
||||||
|
handlers with `_FakeSuperviseResolver`, an in-process stand-in for
|
||||||
|
`PolicyResolver.propose_supervise` / `poll_supervise` backed by the real queue
|
||||||
|
store — so the operator-response and archive contracts are still exercised
|
||||||
|
end-to-end, just through the RPC seam instead of a direct file handle."""
|
||||||
|
|
||||||
import http.client
|
import http.client
|
||||||
import json
|
import json
|
||||||
@@ -44,6 +51,81 @@ from bot_bottle.supervise_server import (
|
|||||||
validate_proposed_file,
|
validate_proposed_file,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Fixed caller identity for the handler tests. The control plane attributes by
|
||||||
|
# (source_ip, identity_token); the fake resolver ignores them and answers for a
|
||||||
|
# fixed bottle, since attribution itself is covered by the orchestrator tests.
|
||||||
|
_SRC = "10.0.0.7"
|
||||||
|
_TOK = "tok"
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeSuperviseResolver:
|
||||||
|
"""Stand-in for `PolicyResolver`'s supervise RPCs, backed by the real queue
|
||||||
|
store (as the orchestrator is). `bottle_id=None` models an unattributed
|
||||||
|
caller (a clean 403 → None); `raises=True` models an unreachable
|
||||||
|
orchestrator (`PolicyResolveError`)."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self, bottle_id: str | None = "dev", raises: bool = False, policy: str = "",
|
||||||
|
) -> None:
|
||||||
|
self.bottle_id = bottle_id
|
||||||
|
self.raises = raises
|
||||||
|
self._policy = policy
|
||||||
|
|
||||||
|
def propose_supervise(
|
||||||
|
self, source_ip, identity_token, *, tool, proposed_file, justification,
|
||||||
|
):
|
||||||
|
del source_ip, identity_token
|
||||||
|
if self.raises:
|
||||||
|
raise supervise_server.PolicyResolveError("orchestrator down")
|
||||||
|
if self.bottle_id is None:
|
||||||
|
return None
|
||||||
|
proposal = _sv.Proposal.new(
|
||||||
|
bottle_slug=self.bottle_id, tool=tool, proposed_file=proposed_file,
|
||||||
|
justification=justification, current_file_hash=_sv.sha256_hex(proposed_file),
|
||||||
|
)
|
||||||
|
_sv.write_proposal(proposal)
|
||||||
|
return proposal.id
|
||||||
|
|
||||||
|
def poll_supervise(self, source_ip, identity_token, proposal_id):
|
||||||
|
del source_ip, identity_token
|
||||||
|
if self.raises:
|
||||||
|
raise supervise_server.PolicyResolveError("orchestrator down")
|
||||||
|
if self.bottle_id is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
response = _sv.read_response(self.bottle_id, proposal_id)
|
||||||
|
except FileNotFoundError:
|
||||||
|
try:
|
||||||
|
_sv.read_proposal(self.bottle_id, proposal_id)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return {"status": _sv.POLL_STATUS_UNKNOWN}
|
||||||
|
return {"status": _sv.POLL_STATUS_PENDING}
|
||||||
|
_sv.archive_proposal(self.bottle_id, proposal_id)
|
||||||
|
return {
|
||||||
|
"status": response.status, "notes": response.notes,
|
||||||
|
"final_file": response.final_file,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Used by list-egress-routes (`_resolved_routes_payload`), unchanged path.
|
||||||
|
def resolve_policy_and_bottle_id(self, source_ip, identity_token=""):
|
||||||
|
del source_ip, identity_token
|
||||||
|
if self.raises:
|
||||||
|
raise supervise_server.PolicyResolveError("orchestrator down")
|
||||||
|
return self._policy, self.bottle_id, {}
|
||||||
|
|
||||||
|
|
||||||
|
def _tools_call(resolver, params, config=None):
|
||||||
|
return handle_tools_call(
|
||||||
|
params, config or ServerConfig(),
|
||||||
|
resolver=resolver, source_ip=_SRC, identity_token=_TOK,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _check(resolver, params):
|
||||||
|
return handle_check_proposal(
|
||||||
|
params, resolver=resolver, source_ip=_SRC, identity_token=_TOK,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# --- Validation ------------------------------------------------------------
|
# --- Validation ------------------------------------------------------------
|
||||||
|
|
||||||
@@ -111,32 +193,35 @@ class TestRpcErrorTaxonomy(unittest.TestCase):
|
|||||||
validate_proposed_file(_sv.TOOL_EGRESS_ALLOW, "routes: nope\n")
|
validate_proposed_file(_sv.TOOL_EGRESS_ALLOW, "routes: nope\n")
|
||||||
|
|
||||||
def test_unknown_tool_in_tools_call_is_client_error(self):
|
def test_unknown_tool_in_tools_call_is_client_error(self):
|
||||||
config = ServerConfig(bottle_slug="dev")
|
|
||||||
with self.assertRaises(_RpcClientError) as cm:
|
with self.assertRaises(_RpcClientError) as cm:
|
||||||
handle_tools_call({"name": "no-such-tool", "arguments": {}}, config)
|
_tools_call(_FakeSuperviseResolver(), {"name": "no-such-tool", "arguments": {}})
|
||||||
self.assertEqual(ERR_INVALID_PARAMS, cm.exception.code)
|
self.assertEqual(ERR_INVALID_PARAMS, cm.exception.code)
|
||||||
|
|
||||||
|
|
||||||
class TestRpcInternalErrorOnIoFailure(unittest.TestCase):
|
class TestRpcInternalErrorOnRpcFailure(unittest.TestCase):
|
||||||
def test_write_proposal_os_error_raises_internal(self):
|
"""A queue RPC that can't reach the orchestrator (or returns unattributed)
|
||||||
config = ServerConfig(
|
surfaces as ERR_INTERNAL — the daemon fails closed rather than leaking the
|
||||||
bottle_slug="dev",
|
cause to the agent."""
|
||||||
)
|
|
||||||
with patch.object(_sv, "write_proposal", side_effect=OSError("disk full")), \
|
_ARGS = {
|
||||||
self.assertRaises(_RpcInternalError) as cm:
|
"name": _sv.TOOL_EGRESS_ALLOW,
|
||||||
handle_tools_call(
|
"arguments": {
|
||||||
{
|
"routes_yaml": "routes:\n - host: example.com\n",
|
||||||
"name": _sv.TOOL_EGRESS_ALLOW,
|
"justification": "x",
|
||||||
"arguments": {
|
},
|
||||||
"routes_yaml": "routes:\n - host: example.com\n",
|
}
|
||||||
"justification": "x",
|
|
||||||
},
|
def test_unreachable_orchestrator_raises_internal(self):
|
||||||
},
|
with self.assertRaises(_RpcInternalError) as cm:
|
||||||
config,
|
_tools_call(_FakeSuperviseResolver(raises=True), self._ARGS)
|
||||||
)
|
|
||||||
self.assertEqual(ERR_INTERNAL, cm.exception.code)
|
self.assertEqual(ERR_INTERNAL, cm.exception.code)
|
||||||
self.assertIsNotNone(cm.exception.__cause__)
|
self.assertIsNotNone(cm.exception.__cause__)
|
||||||
|
|
||||||
|
def test_unattributed_source_raises_internal(self):
|
||||||
|
with self.assertRaises(_RpcInternalError) as cm:
|
||||||
|
_tools_call(_FakeSuperviseResolver(bottle_id=None), self._ARGS)
|
||||||
|
self.assertEqual(ERR_INTERNAL, cm.exception.code)
|
||||||
|
|
||||||
|
|
||||||
# --- JSON-RPC parsing ------------------------------------------------------
|
# --- JSON-RPC parsing ------------------------------------------------------
|
||||||
|
|
||||||
@@ -265,8 +350,8 @@ class TestHandleToolsList(unittest.TestCase):
|
|||||||
class TestHandleToolsCall(unittest.TestCase):
|
class TestHandleToolsCall(unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self._tmp = tempfile.TemporaryDirectory(prefix="supervise-server-test.")
|
self._tmp = tempfile.TemporaryDirectory(prefix="supervise-server-test.")
|
||||||
self._home_patch = self._patch_home(Path(self._tmp.name))
|
self._home_patch = use_bottle_root(Path(self._tmp.name) / ".bot-bottle")
|
||||||
self.config = ServerConfig(bottle_slug="dev")
|
self.resolver = _FakeSuperviseResolver("dev")
|
||||||
_qs.QueueStore("dev").migrate()
|
_qs.QueueStore("dev").migrate()
|
||||||
_as.AuditStore().migrate()
|
_as.AuditStore().migrate()
|
||||||
|
|
||||||
@@ -274,12 +359,9 @@ class TestHandleToolsCall(unittest.TestCase):
|
|||||||
self._home_patch()
|
self._home_patch()
|
||||||
self._tmp.cleanup()
|
self._tmp.cleanup()
|
||||||
|
|
||||||
def _patch_home(self, fake_home: Path):
|
|
||||||
return use_bottle_root(fake_home / ".bot-bottle")
|
|
||||||
|
|
||||||
def _respond_when_proposal_appears(self, status: str, notes: str = "") -> threading.Thread:
|
def _respond_when_proposal_appears(self, status: str, notes: str = "") -> threading.Thread:
|
||||||
"""Background thread: poll the queue for a fresh proposal, write a
|
"""Background thread: poll the queue for a fresh proposal, write a
|
||||||
matching response. Returns the thread so the test can join it."""
|
matching response — the operator half, out of band."""
|
||||||
def runner():
|
def runner():
|
||||||
for _ in range(200):
|
for _ in range(200):
|
||||||
pending = _sv.list_pending_proposals("dev")
|
pending = _sv.list_pending_proposals("dev")
|
||||||
@@ -298,16 +380,13 @@ class TestHandleToolsCall(unittest.TestCase):
|
|||||||
def test_call_round_trips_through_queue(self):
|
def test_call_round_trips_through_queue(self):
|
||||||
responder = self._respond_when_proposal_appears(_sv.STATUS_APPROVED, notes="lgtm")
|
responder = self._respond_when_proposal_appears(_sv.STATUS_APPROVED, notes="lgtm")
|
||||||
try:
|
try:
|
||||||
result = handle_tools_call(
|
result = _tools_call(self.resolver, {
|
||||||
{
|
"name": _sv.TOOL_EGRESS_BLOCK,
|
||||||
"name": _sv.TOOL_EGRESS_BLOCK,
|
"arguments": {
|
||||||
"arguments": {
|
"routes_yaml": "routes:\n - host: example.com\n",
|
||||||
"routes_yaml": "routes:\n - host: example.com\n",
|
"justification": "need example.com",
|
||||||
"justification": "need example.com",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
self.config,
|
})
|
||||||
)
|
|
||||||
finally:
|
finally:
|
||||||
responder.join()
|
responder.join()
|
||||||
self.assertFalse(result["isError"]) # type: ignore[index]
|
self.assertFalse(result["isError"]) # type: ignore[index]
|
||||||
@@ -318,16 +397,13 @@ class TestHandleToolsCall(unittest.TestCase):
|
|||||||
def test_allow_round_trips_through_queue(self):
|
def test_allow_round_trips_through_queue(self):
|
||||||
responder = self._respond_when_proposal_appears(_sv.STATUS_APPROVED, notes="ok")
|
responder = self._respond_when_proposal_appears(_sv.STATUS_APPROVED, notes="ok")
|
||||||
try:
|
try:
|
||||||
result = handle_tools_call(
|
result = _tools_call(self.resolver, {
|
||||||
{
|
"name": _sv.TOOL_EGRESS_ALLOW,
|
||||||
"name": _sv.TOOL_EGRESS_ALLOW,
|
"arguments": {
|
||||||
"arguments": {
|
"routes_yaml": "routes:\n - host: example.com\n",
|
||||||
"routes_yaml": "routes:\n - host: example.com\n",
|
"justification": "need example.com",
|
||||||
"justification": "need example.com",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
self.config,
|
})
|
||||||
)
|
|
||||||
finally:
|
finally:
|
||||||
responder.join()
|
responder.join()
|
||||||
self.assertFalse(result["isError"]) # type: ignore[index]
|
self.assertFalse(result["isError"]) # type: ignore[index]
|
||||||
@@ -338,94 +414,70 @@ class TestHandleToolsCall(unittest.TestCase):
|
|||||||
def test_rejected_response_sets_isError(self):
|
def test_rejected_response_sets_isError(self):
|
||||||
responder = self._respond_when_proposal_appears(_sv.STATUS_REJECTED, notes="nope")
|
responder = self._respond_when_proposal_appears(_sv.STATUS_REJECTED, notes="nope")
|
||||||
try:
|
try:
|
||||||
result = handle_tools_call(
|
result = _tools_call(self.resolver, {
|
||||||
{
|
"name": _sv.TOOL_EGRESS_ALLOW,
|
||||||
"name": _sv.TOOL_EGRESS_ALLOW,
|
"arguments": {
|
||||||
"arguments": {
|
"routes_yaml": "routes:\n - host: example.com\n",
|
||||||
"routes_yaml": "routes:\n - host: example.com\n",
|
"justification": "needed for tests",
|
||||||
"justification": "needed for tests",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
self.config,
|
})
|
||||||
)
|
|
||||||
finally:
|
finally:
|
||||||
responder.join()
|
responder.join()
|
||||||
self.assertTrue(result["isError"]) # type: ignore[index]
|
self.assertTrue(result["isError"]) # type: ignore[index]
|
||||||
|
|
||||||
def test_invalid_tool_name_raises(self):
|
def test_invalid_tool_name_raises(self):
|
||||||
with self.assertRaises(_RpcError) as cm:
|
with self.assertRaises(_RpcError) as cm:
|
||||||
handle_tools_call(
|
_tools_call(self.resolver, {"name": "not-a-tool", "arguments": {}})
|
||||||
{"name": "not-a-tool", "arguments": {}},
|
|
||||||
self.config,
|
|
||||||
)
|
|
||||||
self.assertEqual(ERR_INVALID_PARAMS, cm.exception.code)
|
self.assertEqual(ERR_INVALID_PARAMS, cm.exception.code)
|
||||||
|
|
||||||
def test_missing_justification_raises(self):
|
def test_missing_justification_raises(self):
|
||||||
with self.assertRaises(_RpcError):
|
with self.assertRaises(_RpcError):
|
||||||
handle_tools_call(
|
_tools_call(self.resolver, {
|
||||||
{
|
"name": _sv.TOOL_EGRESS_ALLOW,
|
||||||
"name": _sv.TOOL_EGRESS_ALLOW,
|
"arguments": {"routes_yaml": "routes:\n - host: example.com\n"},
|
||||||
"arguments": {"routes_yaml": "routes:\n - host: example.com\n"},
|
})
|
||||||
},
|
|
||||||
self.config,
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_missing_name_raises(self):
|
def test_missing_name_raises(self):
|
||||||
with self.assertRaises(_RpcError) as cm:
|
with self.assertRaises(_RpcError) as cm:
|
||||||
handle_tools_call({"arguments": {}}, self.config)
|
_tools_call(self.resolver, {"arguments": {}})
|
||||||
self.assertEqual(ERR_INVALID_PARAMS, cm.exception.code)
|
self.assertEqual(ERR_INVALID_PARAMS, cm.exception.code)
|
||||||
|
|
||||||
def test_arguments_must_be_object(self):
|
def test_arguments_must_be_object(self):
|
||||||
with self.assertRaises(_RpcError) as cm:
|
with self.assertRaises(_RpcError) as cm:
|
||||||
handle_tools_call(
|
_tools_call(self.resolver, {"name": _sv.TOOL_EGRESS_ALLOW, "arguments": []})
|
||||||
{
|
|
||||||
"name": _sv.TOOL_EGRESS_ALLOW,
|
|
||||||
"arguments": [],
|
|
||||||
},
|
|
||||||
self.config,
|
|
||||||
)
|
|
||||||
self.assertEqual(ERR_INVALID_PARAMS, cm.exception.code)
|
self.assertEqual(ERR_INVALID_PARAMS, cm.exception.code)
|
||||||
self.assertIn("must be an object", cm.exception.message)
|
self.assertIn("must be an object", cm.exception.message)
|
||||||
|
|
||||||
def test_capability_block_call_raises_unknown_tool(self):
|
def test_capability_block_call_raises_unknown_tool(self):
|
||||||
with self.assertRaises(_RpcError) as cm:
|
with self.assertRaises(_RpcError) as cm:
|
||||||
handle_tools_call(
|
_tools_call(self.resolver, {
|
||||||
{
|
"name": "capability-block",
|
||||||
"name": "capability-block",
|
"arguments": {
|
||||||
"arguments": {
|
"dockerfile": "FROM python:3.13\n",
|
||||||
"dockerfile": "FROM python:3.13\n",
|
"justification": "need git",
|
||||||
"justification": "need git",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
self.config,
|
})
|
||||||
)
|
|
||||||
self.assertEqual(ERR_INVALID_PARAMS, cm.exception.code)
|
self.assertEqual(ERR_INVALID_PARAMS, cm.exception.code)
|
||||||
self.assertIn("unknown tool", cm.exception.message)
|
self.assertIn("unknown tool", cm.exception.message)
|
||||||
|
|
||||||
def test_archives_proposal_after_response(self):
|
def test_archives_proposal_after_response(self):
|
||||||
responder = self._respond_when_proposal_appears(_sv.STATUS_APPROVED)
|
responder = self._respond_when_proposal_appears(_sv.STATUS_APPROVED)
|
||||||
try:
|
try:
|
||||||
handle_tools_call(
|
_tools_call(self.resolver, {
|
||||||
{
|
"name": _sv.TOOL_EGRESS_ALLOW,
|
||||||
"name": _sv.TOOL_EGRESS_ALLOW,
|
"arguments": {
|
||||||
"arguments": {
|
"routes_yaml": "routes:\n - host: example.com\n",
|
||||||
"routes_yaml": "routes:\n - host: example.com\n",
|
"justification": "x",
|
||||||
"justification": "x",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
self.config,
|
})
|
||||||
)
|
|
||||||
finally:
|
finally:
|
||||||
responder.join()
|
responder.join()
|
||||||
# No pending proposals left after archive.
|
# No pending proposals left after the decided poll archives it.
|
||||||
self.assertEqual([], _sv.list_pending_proposals("dev"))
|
self.assertEqual([], _sv.list_pending_proposals("dev"))
|
||||||
|
|
||||||
def test_pending_response_times_out_without_archive(self):
|
def test_pending_response_times_out_without_archive(self):
|
||||||
config = ServerConfig(
|
result = _tools_call(
|
||||||
bottle_slug="dev",
|
self.resolver,
|
||||||
response_timeout_seconds=0.05,
|
|
||||||
)
|
|
||||||
result = handle_tools_call(
|
|
||||||
{
|
{
|
||||||
"name": _sv.TOOL_EGRESS_ALLOW,
|
"name": _sv.TOOL_EGRESS_ALLOW,
|
||||||
"arguments": {
|
"arguments": {
|
||||||
@@ -433,9 +485,8 @@ class TestHandleToolsCall(unittest.TestCase):
|
|||||||
"justification": "need egress",
|
"justification": "need egress",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
config,
|
ServerConfig(response_timeout_seconds=0.05),
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertFalse(result["isError"]) # type: ignore[index]
|
self.assertFalse(result["isError"]) # type: ignore[index]
|
||||||
text = result["content"][0]["text"] # type: ignore[index]
|
text = result["content"][0]["text"] # type: ignore[index]
|
||||||
self.assertIn("status: pending", text)
|
self.assertIn("status: pending", text)
|
||||||
@@ -510,7 +561,7 @@ class TestHttpEndToEnd(unittest.TestCase):
|
|||||||
self.port = s.getsockname()[1]
|
self.port = s.getsockname()[1]
|
||||||
s.close()
|
s.close()
|
||||||
self.server = MCPServer(("127.0.0.1", self.port), MCPHandler)
|
self.server = MCPServer(("127.0.0.1", self.port), MCPHandler)
|
||||||
self.server.config = ServerConfig(bottle_slug="dev")
|
self.server.config = ServerConfig()
|
||||||
self.thread = threading.Thread(
|
self.thread = threading.Thread(
|
||||||
target=self.server.serve_forever, daemon=True,
|
target=self.server.serve_forever, daemon=True,
|
||||||
)
|
)
|
||||||
@@ -552,23 +603,21 @@ class TestHttpEndToEnd(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(ERR_METHOD_NOT_FOUND, result["error"]["code"]) # type: ignore[index]
|
self.assertEqual(ERR_METHOD_NOT_FOUND, result["error"]["code"]) # type: ignore[index]
|
||||||
|
|
||||||
def test_internal_error_returns_err_internal_over_http(self):
|
def test_no_resolver_fails_closed_over_http(self):
|
||||||
with patch.object(
|
# The test server has no policy_resolver wired, so a proposal tools/call
|
||||||
supervise_server._sv, "write_proposal",
|
# fails closed with ERR_INTERNAL rather than queuing anything.
|
||||||
side_effect=OSError("disk full"),
|
result = self._post_jsonrpc({
|
||||||
):
|
"jsonrpc": "2.0",
|
||||||
result = self._post_jsonrpc({
|
"id": 99,
|
||||||
"jsonrpc": "2.0",
|
"method": "tools/call",
|
||||||
"id": 99,
|
"params": {
|
||||||
"method": "tools/call",
|
"name": _sv.TOOL_EGRESS_ALLOW,
|
||||||
"params": {
|
"arguments": {
|
||||||
"name": _sv.TOOL_EGRESS_ALLOW,
|
"routes_yaml": "routes:\n - host: example.com\n",
|
||||||
"arguments": {
|
"justification": "x",
|
||||||
"routes_yaml": "routes:\n - host: example.com\n",
|
|
||||||
"justification": "x",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
})
|
},
|
||||||
|
})
|
||||||
self.assertIn("error", result)
|
self.assertIn("error", result)
|
||||||
self.assertEqual(ERR_INTERNAL, result["error"]["code"]) # type: ignore[index]
|
self.assertEqual(ERR_INTERNAL, result["error"]["code"]) # type: ignore[index]
|
||||||
|
|
||||||
@@ -583,72 +632,23 @@ class TestHttpEndToEnd(unittest.TestCase):
|
|||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
class _FakeResolver:
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
bottle_id: str | None = None,
|
|
||||||
raises: bool = False,
|
|
||||||
policy: str = "",
|
|
||||||
) -> None:
|
|
||||||
self._bottle_id = bottle_id
|
|
||||||
self._raises = raises
|
|
||||||
self._policy = policy
|
|
||||||
self.calls: list[str] = []
|
|
||||||
|
|
||||||
def resolve_bottle_id(self, source_ip: str, identity_token: str = "") -> str | None:
|
|
||||||
del identity_token
|
|
||||||
self.calls.append(source_ip)
|
|
||||||
if self._raises:
|
|
||||||
# Raise the exact class supervise_server catches (it imports
|
|
||||||
# policy_resolver flat inside the bundle, package-side in tests).
|
|
||||||
raise supervise_server.PolicyResolveError("orchestrator down")
|
|
||||||
return self._bottle_id
|
|
||||||
|
|
||||||
def resolve_policy_and_bottle_id(
|
|
||||||
self, source_ip: str, identity_token: str = "",
|
|
||||||
) -> "tuple[str, str | None, dict[str, str]]":
|
|
||||||
del identity_token
|
|
||||||
self.calls.append(source_ip)
|
|
||||||
if self._raises:
|
|
||||||
raise supervise_server.PolicyResolveError("orchestrator down")
|
|
||||||
return self._policy, self._bottle_id, {}
|
|
||||||
|
|
||||||
|
|
||||||
def _handler(resolver: object) -> MCPHandler:
|
def _handler(resolver: object) -> MCPHandler:
|
||||||
"""A bare MCPHandler wired with a server (carrying the resolver) and a
|
"""A bare MCPHandler wired with a server (carrying the resolver) and a
|
||||||
client address, enough to exercise `_attributed_config` off-socket."""
|
client address, enough to exercise the resolver-backed paths off-socket."""
|
||||||
h: MCPHandler = MCPHandler.__new__(MCPHandler)
|
h: MCPHandler = MCPHandler.__new__(MCPHandler)
|
||||||
h.server = types.SimpleNamespace(policy_resolver=resolver) # type: ignore[assignment]
|
h.server = types.SimpleNamespace(policy_resolver=resolver) # type: ignore[assignment]
|
||||||
h.client_address = ("10.0.0.7", 4321)
|
h.client_address = ("10.0.0.7", 4321)
|
||||||
|
h.headers = {} # type: ignore[assignment]
|
||||||
return h
|
return h
|
||||||
|
|
||||||
|
|
||||||
class TestAttributedConfig(unittest.TestCase):
|
class TestResolverFailClosed(unittest.TestCase):
|
||||||
"""Each proposal is attributed to the calling bottle by source IP (PRD
|
"""A dispatch without a resolver is a misconfiguration, not a tenancy mode
|
||||||
0070); a server without a resolver fails closed rather than queuing under an
|
— fail closed rather than queue (or list) anything (PRD 0070)."""
|
||||||
unattributed slug."""
|
|
||||||
|
|
||||||
def test_missing_resolver_fails_closed(self) -> None:
|
def test_missing_resolver_raises(self) -> None:
|
||||||
with self.assertRaises(_RpcInternalError):
|
with self.assertRaises(_RpcInternalError):
|
||||||
_handler(None)._attributed_config(ServerConfig(bottle_slug="dev"))
|
_handler(None)._resolver_or_fail()
|
||||||
|
|
||||||
def test_consolidated_binds_source_ip_bottle(self) -> None:
|
|
||||||
r = _FakeResolver(bottle_id="bottle-x")
|
|
||||||
cfg = _handler(r)._attributed_config(ServerConfig(bottle_slug="ignored"))
|
|
||||||
self.assertEqual("bottle-x", cfg.bottle_slug) # resolved slug wins
|
|
||||||
self.assertEqual(["10.0.0.7"], r.calls)
|
|
||||||
|
|
||||||
def test_unattributed_source_fails_closed(self) -> None:
|
|
||||||
with self.assertRaises(_RpcInternalError):
|
|
||||||
_handler(_FakeResolver(bottle_id=None))._attributed_config(
|
|
||||||
ServerConfig(bottle_slug="x")
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_resolver_error_fails_closed(self) -> None:
|
|
||||||
with self.assertRaises(_RpcInternalError):
|
|
||||||
_handler(_FakeResolver(raises=True))._attributed_config(
|
|
||||||
ServerConfig(bottle_slug="x")
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestResolvedRoutesPayload(unittest.TestCase):
|
class TestResolvedRoutesPayload(unittest.TestCase):
|
||||||
@@ -664,7 +664,7 @@ class TestResolvedRoutesPayload(unittest.TestCase):
|
|||||||
" - host: www.google.com\n"
|
" - host: www.google.com\n"
|
||||||
)
|
)
|
||||||
payload = _handler(
|
payload = _handler(
|
||||||
_FakeResolver(bottle_id="b1", policy=policy)
|
_FakeSuperviseResolver(bottle_id="b1", policy=policy)
|
||||||
)._resolved_routes_payload()
|
)._resolved_routes_payload()
|
||||||
assert payload is not None
|
assert payload is not None
|
||||||
self.assertFalse(payload["isError"]) # type: ignore[index]
|
self.assertFalse(payload["isError"]) # type: ignore[index]
|
||||||
@@ -676,7 +676,7 @@ class TestResolvedRoutesPayload(unittest.TestCase):
|
|||||||
# resolve_client_context swallows resolver errors → deny-all (empty),
|
# resolve_client_context swallows resolver errors → deny-all (empty),
|
||||||
# never another bottle's routes.
|
# never another bottle's routes.
|
||||||
payload = _handler(
|
payload = _handler(
|
||||||
_FakeResolver(raises=True)
|
_FakeSuperviseResolver(raises=True)
|
||||||
)._resolved_routes_payload()
|
)._resolved_routes_payload()
|
||||||
assert payload is not None
|
assert payload is not None
|
||||||
data = json.loads(payload["content"][0]["text"]) # type: ignore[index]
|
data = json.loads(payload["content"][0]["text"]) # type: ignore[index]
|
||||||
@@ -698,7 +698,7 @@ class TestNonBlockingSupervise(unittest.TestCase):
|
|||||||
def setUp(self):
|
def setUp(self):
|
||||||
self._tmp = tempfile.TemporaryDirectory(prefix="supervise-nonblock-test.")
|
self._tmp = tempfile.TemporaryDirectory(prefix="supervise-nonblock-test.")
|
||||||
self._home_patch = use_bottle_root(Path(self._tmp.name) / ".bot-bottle")
|
self._home_patch = use_bottle_root(Path(self._tmp.name) / ".bot-bottle")
|
||||||
self.config = ServerConfig(bottle_slug="dev")
|
self.resolver = _FakeSuperviseResolver("dev")
|
||||||
_qs.QueueStore("dev").migrate()
|
_qs.QueueStore("dev").migrate()
|
||||||
_as.AuditStore().migrate()
|
_as.AuditStore().migrate()
|
||||||
|
|
||||||
@@ -717,9 +717,6 @@ class TestNonBlockingSupervise(unittest.TestCase):
|
|||||||
_sv.write_proposal(p)
|
_sv.write_proposal(p)
|
||||||
return 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 ---
|
# --- pending response carries the id ---
|
||||||
|
|
||||||
def test_pending_text_includes_id_and_pointer(self):
|
def test_pending_text_includes_id_and_pointer(self):
|
||||||
@@ -730,12 +727,13 @@ class TestNonBlockingSupervise(unittest.TestCase):
|
|||||||
|
|
||||||
def test_tools_call_timeout_returns_pending_with_id_and_stays_queued(self):
|
def test_tools_call_timeout_returns_pending_with_id_and_stays_queued(self):
|
||||||
# No responder → the grace window expires → pending, not blocked forever.
|
# No responder → the grace window expires → pending, not blocked forever.
|
||||||
result = handle_tools_call(
|
result = _tools_call(
|
||||||
|
self.resolver,
|
||||||
{
|
{
|
||||||
"name": _sv.TOOL_EGRESS_ALLOW,
|
"name": _sv.TOOL_EGRESS_ALLOW,
|
||||||
"arguments": {"routes_yaml": self._ROUTES, "justification": "x"},
|
"arguments": {"routes_yaml": self._ROUTES, "justification": "x"},
|
||||||
},
|
},
|
||||||
ServerConfig(bottle_slug="dev", response_timeout_seconds=0.05),
|
ServerConfig(response_timeout_seconds=0.05),
|
||||||
)
|
)
|
||||||
self.assertFalse(result["isError"]) # type: ignore[index]
|
self.assertFalse(result["isError"]) # type: ignore[index]
|
||||||
text = result["content"][0]["text"] # type: ignore[index]
|
text = result["content"][0]["text"] # type: ignore[index]
|
||||||
@@ -749,7 +747,7 @@ class TestNonBlockingSupervise(unittest.TestCase):
|
|||||||
def test_check_returns_approved_and_archives(self):
|
def test_check_returns_approved_and_archives(self):
|
||||||
p = self._seed_proposal()
|
p = self._seed_proposal()
|
||||||
_sv.write_response("dev", _sv.Response(proposal_id=p.id, status=_sv.STATUS_APPROVED, notes="ok"))
|
_sv.write_response("dev", _sv.Response(proposal_id=p.id, status=_sv.STATUS_APPROVED, notes="ok"))
|
||||||
result = self._check(p.id)
|
result = _check(self.resolver, {"arguments": {"proposal_id": p.id}})
|
||||||
self.assertFalse(result["isError"])
|
self.assertFalse(result["isError"])
|
||||||
text = result["content"][0]["text"] # type: ignore[index]
|
text = result["content"][0]["text"] # type: ignore[index]
|
||||||
self.assertIn("status: approved", text)
|
self.assertIn("status: approved", text)
|
||||||
@@ -760,13 +758,13 @@ class TestNonBlockingSupervise(unittest.TestCase):
|
|||||||
def test_check_rejected_sets_isError(self):
|
def test_check_rejected_sets_isError(self):
|
||||||
p = self._seed_proposal()
|
p = self._seed_proposal()
|
||||||
_sv.write_response("dev", _sv.Response(proposal_id=p.id, status=_sv.STATUS_REJECTED, notes="no"))
|
_sv.write_response("dev", _sv.Response(proposal_id=p.id, status=_sv.STATUS_REJECTED, notes="no"))
|
||||||
result = self._check(p.id)
|
result = _check(self.resolver, {"arguments": {"proposal_id": p.id}})
|
||||||
self.assertTrue(result["isError"])
|
self.assertTrue(result["isError"])
|
||||||
self.assertIn("status: rejected", result["content"][0]["text"]) # type: ignore[index]
|
self.assertIn("status: rejected", result["content"][0]["text"]) # type: ignore[index]
|
||||||
|
|
||||||
def test_check_pending_when_no_decision_yet(self):
|
def test_check_pending_when_no_decision_yet(self):
|
||||||
p = self._seed_proposal()
|
p = self._seed_proposal()
|
||||||
result = self._check(p.id)
|
result = _check(self.resolver, {"arguments": {"proposal_id": p.id}})
|
||||||
self.assertFalse(result["isError"])
|
self.assertFalse(result["isError"])
|
||||||
text = result["content"][0]["text"] # type: ignore[index]
|
text = result["content"][0]["text"] # type: ignore[index]
|
||||||
self.assertIn("status: pending", text)
|
self.assertIn("status: pending", text)
|
||||||
@@ -774,40 +772,41 @@ class TestNonBlockingSupervise(unittest.TestCase):
|
|||||||
self.assertEqual(1, len(_sv.list_pending_proposals("dev"))) # not archived
|
self.assertEqual(1, len(_sv.list_pending_proposals("dev"))) # not archived
|
||||||
|
|
||||||
def test_check_unknown_id_is_error(self):
|
def test_check_unknown_id_is_error(self):
|
||||||
result = self._check("no-such-proposal")
|
result = _check(self.resolver, {"arguments": {"proposal_id": "no-such-proposal"}})
|
||||||
self.assertTrue(result["isError"])
|
self.assertTrue(result["isError"])
|
||||||
self.assertIn("status: unknown", result["content"][0]["text"]) # type: ignore[index]
|
self.assertIn("status: unknown", result["content"][0]["text"]) # type: ignore[index]
|
||||||
|
|
||||||
def test_check_missing_id_raises(self):
|
def test_check_missing_id_raises(self):
|
||||||
with self.assertRaises(_RpcClientError) as cm:
|
with self.assertRaises(_RpcClientError) as cm:
|
||||||
handle_check_proposal({"arguments": {}}, self.config)
|
_check(self.resolver, {"arguments": {}})
|
||||||
self.assertEqual(ERR_INVALID_PARAMS, cm.exception.code)
|
self.assertEqual(ERR_INVALID_PARAMS, cm.exception.code)
|
||||||
|
|
||||||
def test_check_empty_id_raises(self):
|
def test_check_empty_id_raises(self):
|
||||||
with self.assertRaises(_RpcClientError) as cm:
|
with self.assertRaises(_RpcClientError) as cm:
|
||||||
handle_check_proposal({"arguments": {"proposal_id": " "}}, self.config)
|
_check(self.resolver, {"arguments": {"proposal_id": " "}})
|
||||||
self.assertEqual(ERR_INVALID_PARAMS, cm.exception.code)
|
self.assertEqual(ERR_INVALID_PARAMS, cm.exception.code)
|
||||||
|
|
||||||
def test_check_arguments_must_be_object(self):
|
def test_check_arguments_must_be_object(self):
|
||||||
with self.assertRaises(_RpcClientError) as cm:
|
with self.assertRaises(_RpcClientError) as cm:
|
||||||
handle_check_proposal({"arguments": []}, self.config)
|
_check(self.resolver, {"arguments": []})
|
||||||
self.assertEqual(ERR_INVALID_PARAMS, cm.exception.code)
|
self.assertEqual(ERR_INVALID_PARAMS, cm.exception.code)
|
||||||
|
|
||||||
def test_full_nonblocking_round_trip(self):
|
def test_full_nonblocking_round_trip(self):
|
||||||
# 1. tools/call times out → pending with id
|
# 1. tools/call times out → pending with id
|
||||||
result = handle_tools_call(
|
result = _tools_call(
|
||||||
|
self.resolver,
|
||||||
{
|
{
|
||||||
"name": _sv.TOOL_EGRESS_ALLOW,
|
"name": _sv.TOOL_EGRESS_ALLOW,
|
||||||
"arguments": {"routes_yaml": self._ROUTES, "justification": "x"},
|
"arguments": {"routes_yaml": self._ROUTES, "justification": "x"},
|
||||||
},
|
},
|
||||||
ServerConfig(bottle_slug="dev", response_timeout_seconds=0.05),
|
ServerConfig(response_timeout_seconds=0.05),
|
||||||
)
|
)
|
||||||
pid = _sv.list_pending_proposals("dev")[0].id
|
pid = _sv.list_pending_proposals("dev")[0].id
|
||||||
self.assertIn(pid, result["content"][0]["text"]) # type: ignore[index]
|
self.assertIn(pid, result["content"][0]["text"]) # type: ignore[index]
|
||||||
# 2. operator decides out-of-band
|
# 2. operator decides out-of-band
|
||||||
_sv.write_response("dev", _sv.Response(proposal_id=pid, status=_sv.STATUS_APPROVED, notes="ok"))
|
_sv.write_response("dev", _sv.Response(proposal_id=pid, status=_sv.STATUS_APPROVED, notes="ok"))
|
||||||
# 3. agent resumes by polling — no re-proposing
|
# 3. agent resumes by polling — no re-proposing
|
||||||
poll = self._check(pid)
|
poll = _check(self.resolver, {"arguments": {"proposal_id": pid}})
|
||||||
self.assertFalse(poll["isError"])
|
self.assertFalse(poll["isError"])
|
||||||
self.assertIn("status: approved", poll["content"][0]["text"]) # type: ignore[index]
|
self.assertIn("status: approved", poll["content"][0]["text"]) # type: ignore[index]
|
||||||
self.assertEqual([], _sv.list_pending_proposals("dev")) # resolved + archived
|
self.assertEqual([], _sv.list_pending_proposals("dev")) # resolved + archived
|
||||||
|
|||||||
Reference in New Issue
Block a user