test-driver/lab/http_api.py
tegwick 84848e9a0e 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

223 lines
8.4 KiB
Python

"""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)}/grant">'
f"{fields}"
f'<{tag}{href}{role} id="share-submit" data-td="share-submit">'
f"{share_label}</{tag}></form>"
)
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("/grant"):
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()