lab/app.py (users, tenants, auth, resources, sharing, read/write, revoke, audit), lab/http_api.py (JSON API + browser UI, stdlib only), 20 labelled composable version-stamped mutations, ground-truth matrix. 48 tests pass. Detection against the reference scenario: MECHANICAL 0/10 flagged (correct), DEFECT 6/6, SEMANTIC 2/4 with both inert cases declared. - F-0002: M16 and M18 initially escaped detection entirely. A use case protects exactly what it asserts. Resolved by adding two claims already stated as intent in INTENT.md; the six-mutation catalogue would never have surfaced this. - test-id axis added: stable selectors survive most UI mutations, which would make H-001 trivially false. Mutations now vary on preserves_test_ids so the hypothesis is analysed split by that axis rather than rigged. - M12 (semantic deferred revoke) and M19 (defect race) are behaviourally identical and asserted as such - the discrimination problem as a test. lab/minimal.py removed; superseded by lab/app.py. 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
215 lines
7.9 KiB
Python
215 lines
7.9 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_field = (
|
|
'<label for="subject">Person</label>'
|
|
'<input id="subject" name="subject_id" 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 == "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 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()
|