Alice/Bob/Carol runs end to end, deterministically, replayable from seed. 16 tests pass, no third-party dependencies. - src/testdriver: intent, provenance, world, actions, drivers, observers, oracles, evidence, energy, scenario, runner - lab/minimal.py: the SUT, exposing the independent observation channel required by D-07 - evidence is stratified S1/S2/S3; Runner refuses to attribute S2/S3 to an actor; claims are frozen and provenance-checked at construction - missing evidence yields INCONCLUSIVE, which outranks PASS in the run verdict - EnergyEvents captured, no scoring (H-005 dormant) The observation channel records both stored state and an out-of-band enforcement probe; their disagreement is an invariant and is what detects an authorization defect that leaves the audit trail intact. A seeded RevokeIsCosmetic lab fails the run via both the claim and that invariant. Also closes TD-WP-0001-T02 (stack and commands now exist). 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
191 lines
7.2 KiB
Python
191 lines
7.2 KiB
Python
"""Minimal lab: the seed of the system under test.
|
|
|
|
Deliberately small. Users own resources, share them with read or write
|
|
permission, and revoke that sharing. Every state change is audited.
|
|
|
|
Two access paths exist, and the distinction is the whole point:
|
|
|
|
* the **enforcement path** (`request`) is what an actor uses. It checks
|
|
authorization and can therefore be *wrong* — that is where a seeded
|
|
authorization defect lives.
|
|
* the **observation channel** (`ObservationChannel`) reads stored state directly,
|
|
without authorization. It is the independent channel required by decision
|
|
D-07.
|
|
|
|
An oracle that consulted only stored state would verify the framework's own
|
|
reimplementation of the rules rather than the system's enforcement of them. An
|
|
oracle that consulted only the enforcement path would have no way to notice that
|
|
enforcement and record disagree. test-driver observes both, and treats
|
|
disagreement between them as meaningful in its own right — that disagreement is
|
|
the precise signature of the M05 authorization defect.
|
|
|
|
TD-WP-0002-T05 grows this into the full lab with an HTTP API, a browser UI and
|
|
the labelled mutation catalogue. It is kept in-process here so that T04 can prove
|
|
the kernel without dragging in a web stack.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass, field
|
|
from typing import Any, Literal
|
|
|
|
Permission = Literal["READ", "WRITE"]
|
|
|
|
|
|
class Denied(Exception):
|
|
"""The enforcement path refused the request."""
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class AuditRecord:
|
|
sequence: int
|
|
event: str
|
|
actor_id: str
|
|
resource_id: str | None = None
|
|
subject_id: str | None = None
|
|
permission: str | None = None
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class MinimalLab:
|
|
"""In-process resource-sharing service."""
|
|
|
|
version: str = "lab-0.1.0-baseline"
|
|
users: dict[str, str] = field(default_factory=dict) # user_id -> token
|
|
_tokens: dict[str, str] = field(default_factory=dict) # token -> user_id
|
|
resources: dict[str, dict[str, Any]] = field(default_factory=dict)
|
|
grants: dict[tuple[str, str], Permission] = field(default_factory=dict)
|
|
audit: list[AuditRecord] = field(default_factory=list)
|
|
_seq: int = 0
|
|
|
|
# -- setup -----------------------------------------------------------
|
|
|
|
def add_user(self, user_id: str) -> str:
|
|
token = f"tok-{user_id}"
|
|
self.users[user_id] = token
|
|
self._tokens[token] = user_id
|
|
return token
|
|
|
|
def _audit(self, event: str, actor_id: str, **kw: Any) -> None:
|
|
self._seq += 1
|
|
self.audit.append(AuditRecord(self._seq, event, actor_id, **kw))
|
|
|
|
def _whoami(self, token: str) -> str:
|
|
if token not in self._tokens:
|
|
raise Denied("unknown token")
|
|
return self._tokens[token]
|
|
|
|
# -- enforcement path (what actors use) ------------------------------
|
|
|
|
def request(self, token: str, op: str, **args: Any) -> Any:
|
|
"""The single entry point actors go through. Authorization is enforced here."""
|
|
user_id = self._whoami(token)
|
|
handler = getattr(self, f"_op_{op}", None)
|
|
if handler is None:
|
|
raise Denied(f"unknown operation {op!r}")
|
|
return handler(user_id, **args)
|
|
|
|
def _op_create_resource(self, user_id: str, resource_id: str, content: str) -> dict:
|
|
self.resources[resource_id] = {"owner": user_id, "content": content}
|
|
self._audit("create", user_id, resource_id=resource_id)
|
|
return {"resource_id": resource_id}
|
|
|
|
def _op_read_resource(self, user_id: str, resource_id: str) -> dict:
|
|
resource = self.resources.get(resource_id)
|
|
if resource is None:
|
|
raise Denied("no such resource")
|
|
if not self._may_read(user_id, resource_id):
|
|
raise Denied("not authorized to read")
|
|
return {"resource_id": resource_id, "content": resource["content"]}
|
|
|
|
def _op_grant(
|
|
self, user_id: str, resource_id: str, subject_id: str, permission: Permission
|
|
) -> dict:
|
|
resource = self.resources.get(resource_id)
|
|
if resource is None or resource["owner"] != user_id:
|
|
raise Denied("only the owner may grant")
|
|
self.grants[(resource_id, subject_id)] = permission
|
|
self._audit(
|
|
"grant", user_id, resource_id=resource_id,
|
|
subject_id=subject_id, permission=permission,
|
|
)
|
|
return {"granted": permission}
|
|
|
|
def _op_revoke(self, user_id: str, resource_id: str, subject_id: str) -> dict:
|
|
resource = self.resources.get(resource_id)
|
|
if resource is None or resource["owner"] != user_id:
|
|
raise Denied("only the owner may revoke")
|
|
self.grants.pop((resource_id, subject_id), None)
|
|
self._audit("revoke", user_id, resource_id=resource_id, subject_id=subject_id)
|
|
return {"revoked": True}
|
|
|
|
def _may_read(self, user_id: str, resource_id: str) -> bool:
|
|
"""The authorization rule as the system actually enforces it."""
|
|
resource = self.resources.get(resource_id)
|
|
if resource is None:
|
|
return False
|
|
if resource["owner"] == user_id:
|
|
return True
|
|
return (resource_id, user_id) in self.grants
|
|
|
|
|
|
class ObservationChannel:
|
|
"""Independent read access to lab state — decision D-07.
|
|
|
|
Bypasses authorization deliberately. This is the channel test-driver requires
|
|
of any system under test, and the main integration burden the framework
|
|
imposes on an adopter.
|
|
"""
|
|
|
|
def __init__(self, lab: MinimalLab) -> None:
|
|
self._lab = lab
|
|
|
|
@property
|
|
def version(self) -> str:
|
|
return self._lab.version
|
|
|
|
def state_permission(self, user_id: str, resource_id: str) -> str | None:
|
|
"""What the stored record says, independent of any enforcement decision."""
|
|
resource = self._lab.resources.get(resource_id)
|
|
if resource is None:
|
|
return None
|
|
if resource["owner"] == user_id:
|
|
return "OWNER"
|
|
return self._lab.grants.get((resource_id, user_id))
|
|
|
|
def probe_read(self, user_id: str, resource_id: str) -> bool:
|
|
"""Exercise the enforcement path out-of-band and report what it did.
|
|
|
|
This uses the subject's own credentials, which can look like a violation
|
|
of actor isolation but is not: independence means the *actor's report* is
|
|
never the evidence. The observer issues its own request and records the
|
|
raw outcome. No actor is ever asked whether it succeeded.
|
|
"""
|
|
token = self._lab.users.get(user_id)
|
|
if token is None:
|
|
return False
|
|
try:
|
|
self._lab.request(token, "read_resource", resource_id=resource_id)
|
|
except Denied:
|
|
return False
|
|
return True
|
|
|
|
def audit_events(self, resource_id: str) -> list[dict[str, Any]]:
|
|
return [
|
|
{
|
|
"sequence": r.sequence,
|
|
"event": r.event,
|
|
"actor_id": r.actor_id,
|
|
"subject_id": r.subject_id,
|
|
"permission": r.permission,
|
|
}
|
|
for r in self._lab.audit
|
|
if r.resource_id == resource_id
|
|
]
|
|
|
|
|
|
def build_baseline() -> tuple[MinimalLab, dict[str, str]]:
|
|
"""Known initial state, so that runs replay from the same starting point."""
|
|
lab = MinimalLab()
|
|
tokens = {user: lab.add_user(user) for user in ("alice", "bob", "carol")}
|
|
return lab, tokens
|