Phase 4: kids' Windows computer client (AUD-97)

Adds windows/, the Python client for the kids' Windows computers, and proves
both halves of the Phase 4 definition of done on the real box
(DESKTOP-TE2RHV2).

The client:
- subscribes, retained, to its own config on the 8883 TLS device plane with its
  own broker account (FR-3, FR-6);
- keeps the last valid config on disk and loads it before connecting, so a
  broker outage changes nothing (FR-14);
- terminates every process on the blocklist while the device is effectively
  locked, with the allowlist winning and the client's own tree plus critical
  system processes protected (FR-4, FR-16);
- publishes presence and daily per-app usage; the day's totals are held on disk
  and republished when the link returns (FR-7);
- shows an always-on-top warning window naming what is blocked, why and until
  when (FR-9).

install-scheduled-task.ps1 registers the logon Scheduled Task for the kid's
account with an interactive token, and grants that account Modify on the state
directory: a file created from an elevated shell inherits Administrators only,
and the limited task is otherwise refused with Access denied.

Evidence (windows/EVIDENCE.md, from the machine's own log): notepad.exe killed
inside a scheduled window (pid 16600, then 9724), the device online in the
Control Service with locked/until/reason, a telemetry row for the device
(app=690s), enforcement continuing with the broker unreachable
(192.168.20.13:8884), the warning window on the interactive desktop, and the
Scheduled Task registered at logon as New Comp.

Domains are deliberately not reported: Windows has no cheap per-process DNS
attribution, so the client sends "domains": [] and the gap is documented rather
than guessed.

54 unit tests, run with `make windows-test` (no broker, no Windows, no psutil).

AGENTS.md §11 is now 10 of 10.
This commit is contained in:
2026-10-14 15:00:22 +11:00
parent eb300b6701
commit 1275b1baaa
22 changed files with 2799 additions and 13 deletions

View File

@@ -0,0 +1,268 @@
"""The device config and its on-disk cache (AGENTS.md FR-14).
``restrict/<device>/config`` carries the desired state, exactly as
``internal/mqtt/payload.go`` publishes it. This module parses that payload and
keeps the last valid copy on disk, so a device that cannot reach the broker
still knows its rules. Only a valid, not-older config replaces the cache:
a malformed or stale message never leaves the device without rules.
"""
from __future__ import annotations
import json
import os
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Iterable
# The schema version this client understands. A payload with a higher version is
# rejected rather than guessed at; the cached config stays in force.
CONFIG_VERSION = 1
def parse_time(value: str | None) -> datetime | None:
"""Parse an RFC 3339 timestamp as the Control Service writes it.
Accepts a trailing ``Z`` and an optional fractional part. Returns ``None``
for an empty or unparseable value, so a bad timestamp downgrades to "no end
time" instead of raising.
"""
if not value:
return None
text = value.strip()
if text.endswith("Z") or text.endswith("z"):
text = text[:-1] + "+00:00"
try:
moment = datetime.fromisoformat(text)
except ValueError:
return None
if moment.tzinfo is None:
moment = moment.replace(tzinfo=timezone.utc)
return moment
def bare(name: str) -> str:
"""Normalise a process or list entry for comparison.
``notepad.exe``, ``Notepad.EXE`` and ``notepad`` are the same app. The
Windows process list reports ``.exe``; a blocklist entry may or may not.
"""
value = (name or "").strip().casefold()
if value.endswith(".exe"):
value = value[:-4]
return value
@dataclass
class DeviceConfig:
"""The desired state for one device (TRANSPORT_DESIGN.md, Phase 3)."""
v: int = CONFIG_VERSION
device_id: int = 0
name: str = ""
platform: str = "windows"
locked: bool = False
until: str | None = None
allow: list[str] = field(default_factory=list)
block: list[str] = field(default_factory=list)
reason: str = ""
grant_until: str | None = None
generated_at: str | None = None
# -- parsing -----------------------------------------------------------
@classmethod
def from_dict(cls, raw: dict[str, Any]) -> "DeviceConfig":
"""Parse a payload, ignoring unknown fields (forward compatible).
Raises ``ValueError`` on a payload this client cannot honour, so the
caller keeps the last good config.
"""
version = raw.get("v", CONFIG_VERSION)
try:
version = int(version)
except (TypeError, ValueError) as exc:
raise ValueError(f"config v is not an integer: {version!r}") from exc
if version > CONFIG_VERSION:
raise ValueError(f"config v={version} is newer than this client ({CONFIG_VERSION})")
return cls(
v=version,
device_id=int(raw.get("device_id") or 0),
name=str(raw.get("name") or ""),
platform=str(raw.get("platform") or "windows"),
locked=bool(raw.get("locked")),
until=_str_or_none(raw.get("until")),
allow=_string_list(raw.get("allow")),
block=_string_list(raw.get("block")),
reason=str(raw.get("reason") or ""),
grant_until=_str_or_none(raw.get("grant_until")),
generated_at=_str_or_none(raw.get("generated_at")),
)
@classmethod
def from_json(cls, payload: str | bytes) -> "DeviceConfig":
if isinstance(payload, (bytes, bytearray)):
payload = payload.decode("utf-8", "replace")
data = json.loads(payload)
if not isinstance(data, dict):
raise ValueError("config payload is not a JSON object")
return cls.from_dict(data)
def to_json(self) -> str:
return json.dumps(self.to_dict(), sort_keys=True)
def to_dict(self) -> dict[str, Any]:
return {
"v": self.v,
"device_id": self.device_id,
"name": self.name,
"platform": self.platform,
"locked": self.locked,
"until": self.until,
"allow": list(self.allow),
"block": list(self.block),
"reason": self.reason,
"grant_until": self.grant_until,
"generated_at": self.generated_at,
}
# -- effective state --------------------------------------------------
def until_at(self) -> datetime | None:
return parse_time(self.until)
def grant_at(self) -> datetime | None:
return parse_time(self.grant_until)
def is_blocking(self, now: datetime | None = None) -> bool:
"""Whether the device must block right now.
This mirrors the Control Service's *effective* state
(``internal/store/schedules.go`` ``EffectiveState``): the payload already
carries the resolved ``locked`` flag, so the client only has to apply
the clock. An expired ``until`` lifts the block locally — the same
instant the Control Service would clear it — a live ``grant_until``
never blocks, and an indefinite lock (``until`` absent) never lifts.
"""
if not self.locked:
return False
moment = now or datetime.now(timezone.utc)
grant = self.grant_at()
if grant is not None and grant > moment:
return False
until = self.until_at()
if until is not None and moment >= until:
return False
return True
def blocked_names(self) -> set[str]:
"""The blocklist as normalised names, minus the allowlist (FR-4)."""
allowed = {bare(item) for item in self.allow}
return {bare(item) for item in self.block if bare(item)} - allowed
def allowed_names(self) -> set[str]:
return {bare(item) for item in self.allow if bare(item)}
def is_blocked_name(self, process_name: str) -> bool:
"""Whether one process name is blocked, with the allowlist winning."""
value = bare(process_name)
if not value:
return False
if value in self.allowed_names():
return False
return value in self.blocked_names()
@property
def reason_text(self) -> str:
"""A short human reason for the overlay (FR-9)."""
return {
"schedule": "it is inside a scheduled screen-time window",
"manual": "a parent locked this computer",
"grant": "",
}.get(self.reason, self.reason or "screen-time rules are active")
def generation_key(self) -> tuple[str, int]:
"""A sortable key for "is this config newer?".
The Control Service republishes a config whenever the state changes, so
``generated_at`` is normally enough. The fallback keeps a payload with a
missing timestamp acceptable rather than dropping a real update.
"""
return (self.generated_at or "", self.device_id)
def _str_or_none(value: Any) -> str | None:
return None if value is None else str(value)
def _string_list(value: Any) -> list[str]:
if value is None:
return []
if isinstance(value, str):
return [value]
if isinstance(value, Iterable):
return [str(item) for item in value if str(item).strip()]
return []
class ConfigCache:
"""The last valid config on disk (FR-14 "keep last state").
A device that cannot reach the broker keeps enforcing what it already has.
The cache is written atomically, so a power cut cannot leave a half-written
file, and a cache that cannot be read is treated as empty rather than fatal.
"""
def __init__(self, path: str | os.PathLike[str]):
self.path = Path(path)
self._current: DeviceConfig | None = None
@property
def current(self) -> DeviceConfig | None:
"""The in-memory config, loading from disk on first use."""
if self._current is None:
self._current = self._read()
return self._current
def load(self) -> DeviceConfig | None:
return self.current
def save(self, config: DeviceConfig) -> None:
"""Persist a config and make it current."""
self.path.parent.mkdir(parents=True, exist_ok=True)
temp = self.path.with_suffix(self.path.suffix + ".tmp")
with open(temp, "w", encoding="utf-8") as handle:
handle.write(config.to_json())
handle.flush()
os.fsync(handle.fileno())
os.replace(temp, self.path)
self._current = config
def accept(self, payload: str | bytes) -> DeviceConfig | None:
"""Validate and store a payload.
Returns the stored config, or ``None`` when the payload is rejected (a
malformed message, a newer schema, or an older ``generated_at`` than the
cache). A rejection leaves the current config untouched.
"""
try:
candidate = DeviceConfig.from_json(payload)
except (ValueError, TypeError):
# json.JSONDecodeError is a ValueError. A broker bug or a hostile
# payload must never leave the device without rules (FR-14).
return None
existing = self.current
if existing is not None and candidate.generation_key() < existing.generation_key():
return None
self.save(candidate)
return candidate
# ---------------------------------------------------------------------
def _read(self) -> DeviceConfig | None:
try:
with open(self.path, "r", encoding="utf-8") as handle:
return DeviceConfig.from_json(handle.read())
except FileNotFoundError:
return None
except (OSError, ValueError, json.JSONDecodeError):
return None