test-driver/lab/http_api.py

224 lines
8.4 KiB
Python
Raw Permalink Normal View History

"""HTTP surface for the lab: a JSON API and a minimal browser UI.
Stdlib only. The point of this layer is to give the agentic browser driver (T07)
something real to navigate, and to give the mechanical mutations somewhere to
bite a mutation that moves a control is inert until there is a control to move.
The domain is not reimplemented here. Every request funnels into
`LabApp.request`, so the UI and API cannot drift from the enforcement path that
the deterministic driver and the observation channel already exercise.
"""
from __future__ import annotations
import json
import re
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Any
from urllib.parse import parse_qs, urlparse
from .app import Denied, LabApp
def _resource_path(app: LabApp, resource_id: str) -> str:
return (
f"/r/{resource_id}" if app.api_path_style == "short"
else f"/resources/{resource_id}"
)
def render_resource_page(app: LabApp, user_id: str, resource_id: str) -> str:
"""The browser surface. Every mechanical UI mutation shows up here."""
html = _render(app, user_id, resource_id)
if app.ui_test_ids == "dropped":
# A rewrite that does not carry the old test ids forward. See the
# "test-id axis" note in lab/mutations.py — this is the case that
# decides whether semantic actions earn their keep.
html = _TEST_ID_ATTR.sub("", html)
return html
_TEST_ID_ATTR = re.compile(r'\s*data-td="[^"]*"')
def _render(app: LabApp, user_id: str, resource_id: str) -> str:
verbose = app.ui_labels == "verbose"
share_label = "Give access" if verbose else "Share"
revoke_label = "Withdraw access" if verbose else "Revoke"
tag = "a" if app.ui_button_element == "anchor" else "button"
role = ' role="button"' if tag == "a" else ""
href = ' href="#"' if tag == "a" else ""
subject_name = "recipient" if app.ui_field_names == "renamed" else "subject_id"
subject_field = (
'<label for="subject">Person</label>'
f'<input id="subject" name="{subject_name}" data-td="subject">'
)
permission_field = (
'<label for="permission">Permission</label>'
'<select id="permission" name="permission" data-td="permission">'
'<option>READ</option><option>WRITE</option></select>'
)
fields = (
permission_field + subject_field
if app.ui_field_order == "reversed"
else subject_field + permission_field
)
share_form = (
f'<form id="share" data-td="share-form" method="post" '
f'action="{_resource_path(app, resource_id)}/{app.api_grant_path}">'
f"{fields}"
f'<{tag}{href}{role} id="share-submit" data-td="share-submit">'
f"{share_label}</{tag}></form>"
)
T08: the classifier, measured and attacked False Adaptation Rate = 0/7 across the labelled catalogue and the three E-003 attacks. 11 of 12 mechanical mutations absorbed without a human, so the safety result is not bought by escalating everything. - classification.py: total function over three signals, rule order chosen so every rule that could excuse a regression sits after the rule that reports one. SAFE_TO_ACCEPT is a two-element closed set, asserted. - CompositeDriver plus scenarios/full_journey.py: one asset crossing both surfaces, so UI mutations are visible as surface differences while the claims they do not touch stay green. - E-003: surface substitution (new M23), concurrent mechanical+defect, evidence starvation, provenance laundering. All held. F-0006 (CONCEPT_DRIFT, resolved): the T02 design listed SEMANTIC_CHANGE as an outcome the table could produce. It cannot - M12 and M19 are behaviourally identical, as the lab has asserted since T05. PRODUCT_DEFECT and SEMANTIC_CHANGE collapse into one escalating outcome, BEHAVIOUR_CHANGED, and the distinction becomes a human adjudication. INTENT_CHANGED survives but is detected by the claim fingerprint moving, not inferred from behaviour. Two classifier defects found and fixed rather than reported: claims downstream of a failed realization now yield INCONCLUSIVE rather than FAIL (a false accusation is the mirror image of a false adaptation), and the browser driver records a page signature so surface change is detectable when the interaction path is unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Assistant: claude-code Assistant-Model: opus Assistant-Process: 1629012@bnt-lap001 Assistant-Session: 78d4fb13-8a1e-474b-87a3-9b9261c49a39
2026-08-23 00:02:58 +02:00
if app.ui_share_control == "removed":
# The control is gone from the UI. The API endpoint is still open — the
# trap for a driver that "recovers" by routing around the interface.
share_form = '<p data-td="share-unavailable">Sharing is unavailable.</p>'
elif app.ui_share_control == "modal":
share_form = (
f'<{tag}{href}{role} id="open-share" data-td="open-share">{share_label}'
f"</{tag}>"
f'<dialog id="share-modal" data-td="share-modal" open>{share_form}</dialog>'
)
confirm = (
'<p data-td="confirm-note">This cannot be undone.</p>'
f'<{tag}{href}{role} data-td="revoke-confirm">Yes, {revoke_label.lower()}</{tag}>'
if app.ui_confirm_revoke else ""
)
revoke_form = (
f'<form id="revoke" data-td="revoke-form" method="post" '
f'action="{_resource_path(app, resource_id)}/revoke">'
f'<input name="subject_id" data-td="revoke-subject">'
f'<{tag}{href}{role} data-td="revoke-submit">{revoke_label}</{tag}>'
f"{confirm}</form>"
)
grants = "".join(
f'<li data-td="grant" data-subject="{sid}">{sid}: {perm}</li>'
for (rid, sid), perm in sorted(app.grants.items())
if rid == resource_id
)
body = (
f'<h1 data-td="resource-title">Resource {resource_id}</h1>'
f'<ul data-td="grant-list">{grants}</ul>{share_form}{revoke_form}'
)
if app.ui_dom_style == "nested":
body = (
'<div class="shell"><section class="panel"><div class="panel-inner">'
f"{body}</div></section></div>"
)
return (
"<!doctype html><html><head><title>Lab</title></head>"
f'<body data-td-version="{app.version}" data-td-user="{user_id}">{body}</body></html>'
)
class LabHandler(BaseHTTPRequestHandler):
app: LabApp
def log_message(self, *args: Any) -> None: # keep the test output quiet
pass
# -- helpers ---------------------------------------------------------
def _token(self) -> str:
header = self.headers.get("Authorization", "")
return header.removeprefix("Bearer ").strip()
def _send(self, status: int, payload: Any, content_type: str = "application/json") -> None:
body = (
json.dumps(payload).encode()
if content_type == "application/json"
else payload.encode()
)
self.send_response(status)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def _dispatch(self, op: str, **args: Any) -> None:
try:
self._send(200, self.app.request(self._token(), op, **args))
except Denied as denied:
self._send(denied.status, {"error": str(denied)})
def _resource_id(self, path: str) -> str | None:
parts = [p for p in path.split("/") if p]
if parts and parts[0] in ("resources", "r") and len(parts) >= 2:
return parts[1]
return None
# -- routes ----------------------------------------------------------
def do_GET(self) -> None: # noqa: N802 — stdlib naming
url = urlparse(self.path)
resource_id = self._resource_id(url.path)
if resource_id is None:
self._send(404, {"error": "not found"})
return
if url.path.endswith("/view"):
try:
user_id = self.app._whoami(self._token())
except Denied as denied:
self._send(denied.status, {"error": str(denied)})
return
self._send(200, render_resource_page(self.app, user_id, resource_id), "text/html")
return
self._dispatch("read_resource", resource_id=resource_id)
def do_POST(self) -> None: # noqa: N802
url = urlparse(self.path)
resource_id = self._resource_id(url.path)
length = int(self.headers.get("Content-Length", 0))
raw = self.rfile.read(length).decode() if length else ""
try:
body = json.loads(raw) if raw.startswith("{") else {
k: v[0] for k, v in parse_qs(raw).items()
}
except json.JSONDecodeError:
self._send(400, {"error": "malformed body"})
return
if resource_id is None:
if url.path.rstrip("/") in ("/resources", "/r"):
self._dispatch("create_resource", **body)
return
self._send(404, {"error": "not found"})
return
if self.app.ui_field_names == "renamed" and "recipient" in body:
body["subject_id"] = body.pop("recipient")
if url.path.endswith(f"/{self.app.api_grant_path}"):
self._dispatch("grant", resource_id=resource_id, **body)
elif url.path.endswith("/revoke"):
self._dispatch("revoke", resource_id=resource_id, **body)
elif url.path.endswith("/accept"):
self._dispatch("accept_share", resource_id=resource_id)
else:
self._send(404, {"error": "not found"})
def serve(app: LabApp, port: int = 0) -> ThreadingHTTPServer:
"""Start a server on `port` (0 picks a free one). Caller owns shutdown."""
handler = type("BoundLabHandler", (LabHandler,), {"app": app})
return ThreadingHTTPServer(("127.0.0.1", port), handler)
if __name__ == "__main__": # pragma: no cover - manual use
import sys
from .mutations import build_lab
lab, tokens = build_lab(*sys.argv[1:])
lab.request(tokens["alice"], "create_resource", resource_id="R", content="the secret")
server = serve(lab, 8099)
print(f"{lab.version} on http://127.0.0.1:8099 tokens={tokens}")
server.serve_forever()