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

@@ -189,14 +189,15 @@ The Hub interface is frozen in `central_ai_llm_master/docs/ask-contract.md`.
## 11. Build order ## 11. Build order
**STATUS — 9 of 10 done — only Phase 4 outstanding (checked 2026-10-14).** Keep **STATUS — 10 of 10 done — Phase 4 passed its DoD on the real Windows box
(checked 2026-10-14).** Keep
this list current: tick a box when its DoD passes. **The project is DONE only when this list current: tick a box when its DoD passes. **The project is DONE only when
every box is ticked.** every box is ticked.**
- [x] Phase 1 — Skeleton - [x] Phase 1 — Skeleton
- [x] Phase 2 — Devices and state - [x] Phase 2 — Devices and state
- [x] Phase 3 — MQTT - [x] Phase 3 — MQTT
- [ ] **Phase 4 — Computer client (Windows)** ← REMAINING - [x] Phase 4 — Computer client (Windows)
- [x] Phase 5 — Phone client (no-wipe) - [x] Phase 5 — Phone client (no-wipe)
- [x] Phase 6 — Schedules, blocklists, allowlists - [x] Phase 6 — Schedules, blocklists, allowlists
- [x] Phase 7 — Telemetry and usage - [x] Phase 7 — Telemetry and usage

View File

@@ -13,7 +13,7 @@ MQTT_VERIFY_PORT ?= 18883
MQTT_IMAGE ?= eclipse-mosquitto:latest MQTT_IMAGE ?= eclipse-mosquitto:latest
.PHONY: help test vet build up down restart logs ps hash-password deps fmt \ .PHONY: help test vet build up down restart logs ps hash-password deps fmt \
mqtt-tls mqtt-verify mqtt-config verify-phase8 mqtt-tls mqtt-verify mqtt-config verify-phase8 verify-phase9 windows-test
help: help:
@echo "make test run go test ./... in golang:1.25-alpine" @echo "make test run go test ./... in golang:1.25-alpine"
@@ -28,6 +28,7 @@ help:
@echo "make mqtt-tls generate the lab CA and broker certificate" @echo "make mqtt-tls generate the lab CA and broker certificate"
@echo "make mqtt-config build the scratch broker config in deploy/mosquitto/build/" @echo "make mqtt-config build the scratch broker config in deploy/mosquitto/build/"
@echo "make mqtt-verify prove TLS + credentials + topic ACLs on a scratch broker" @echo "make mqtt-verify prove TLS + credentials + topic ACLs on a scratch broker"
@echo "make windows-test run the kids' Windows client unit tests (python -m unittest)"
@echo "make verify-phase8 prove an API lock reaches a device in under a second" @echo "make verify-phase8 prove an API lock reaches a device in under a second"
@echo "make verify-phase9 prove the Hub answers and the Apprise alerts deliver" @echo "make verify-phase9 prove the Hub answers and the Apprise alerts deliver"
@@ -79,6 +80,11 @@ mqtt-verify: mqtt-config
MQTT_VERIFY_PORT=$(MQTT_VERIFY_PORT) MQTT_IMAGE=$(MQTT_IMAGE) \ MQTT_VERIFY_PORT=$(MQTT_VERIFY_PORT) MQTT_IMAGE=$(MQTT_IMAGE) \
deploy/mosquitto/verify-isolation.sh deploy/mosquitto/verify-isolation.sh
# --- Windows client (Phase 4) ------------------------------------------
# Pure logic only: no broker, no Windows, and no psutil needed.
windows-test:
cd windows && python3 -m unittest discover -s tests -t .
# Phase 8 definition of done: lock through the admin API, measured until a real # Phase 8 definition of done: lock through the admin API, measured until a real
# broker subscriber receives the retained config. Never touches the 1883 plane. # broker subscriber receives the retained config. Never touches the 1883 plane.
verify-phase8: verify-phase8:

View File

@@ -5,13 +5,14 @@ Internet Use Control project (`SYSTEM_MAP.md`). It holds schedules, blocklists,
per-device state and usage, and serves the web dashboard. Only it publishes per-device state and usage, and serves the web dashboard. Only it publishes
device config. device config.
**Project status: 9 of 10 phases done.** Phases 1–3 and 5–10 are complete (Phase 10, **Project status: 10 of 10 phases done.** Phases 1–10 are complete, including
this documentation set, included). **Phase 4 — the Windows computer client — is **Phase 4 — the kids' Windows computer client** in `windows/` (Python, MQTT
deliberately deferred and is NOT built.** There is no Python computer client and config, process blocking, telemetry, warning overlay, keep-last-state, installed
no Windows process blocking in this repository. The only enforcement client is as a logon Scheduled Task). Evidence: `windows/EVIDENCE.md`. The enforcement
the kids' Android app in `android/`. See `AGENTS.md` §11. clients are the kids' Android app in `android/` and the Windows client in
`windows/`. See `AGENTS.md` §11.
This repository contains **Phases 1–3 and 5–10** (AGENTS.md §11): This repository contains **Phases 1–10** (AGENTS.md §11):
- Go service with its own image (`Dockerfile`) and compose file. - Go service with its own image (`Dockerfile`) and compose file.
- SQLite schema for the whole data model (§7), applied through versioned - SQLite schema for the whole data model (§7), applied through versioned
@@ -539,6 +540,32 @@ cache in `.gocache/` (gitignored), so nothing in the repository ends up owned by
root. `make mqtt-verify` never touches the shared Mosquitto: it stages a scratch root. `make mqtt-verify` never touches the shared Mosquitto: it stages a scratch
broker on `127.0.0.1:18883` and removes it afterwards. broker on `127.0.0.1:18883` and removes it afterwards.
## Kids' Windows computer client (Phase 4)
`windows/` is the kids' computer client: Python, one process, installed as a
Scheduled Task at logon. It subscribes, retained, to its own config on the 8883
device plane, and while the device is locked it terminates any process on the
blocklist that is not on the allowlist (FR-3, FR-4).
- **Keep last state (FR-14).** The last valid config is cached on disk and loaded
before the first connect, so a broker outage changes nothing.
- **Presence (FR-6).** A retained `{"online": true}` status plus the broker's
Last Will `{"online": false}`; a force-stop or a power-off shows up as an
offline device.
- **Usage (FR-7).** Daily per-app seconds, sampled from the apps that own a
visible window, published as day totals on the device's telemetry topic.
- **Warning (FR-9).** An always-on-top Tkinter window naming what is blocked, why
and until when.
- **Domains are not reported.** Windows has no cheap per-process DNS
attribution, so the client sends `"domains": []` rather than a guess; there is
no Windows site filter in this phase.
`windows/EVIDENCE.md` holds the transcript of the Definition of Done on the real
machine: a listed process killed inside a scheduled window, the device online in
this service, a telemetry row for it, enforcement continuing with the broker
unreachable, and the Scheduled Task registered at logon. Run the client's tests
with `make windows-test`.
## Kids' Android client (Phase 5.1) ## Kids' Android client (Phase 5.1)
`android/` is the kids' phone app: a normal Gradle project with its own `android/` is the kids' phone app: a normal Gradle project with its own
@@ -575,6 +602,10 @@ emulator on `.27`; see `android/README.md` and `android/tools/emu-proof.sh`.
``` ```
cmd/control/ service entry point (+ hash-password, mqtt-clients, rollup, keycode, alerts, usage) cmd/control/ service entry point (+ hash-password, mqtt-clients, rollup, keycode, alerts, usage)
windows/ kids' Windows computer client (Python; process block, overlay, keep-last-state)
webrestrict/ MQTT link, block rule, enforcer, telemetry, warning window, agent loop
install-scheduled-task.ps1 logon Scheduled Task (+ the state directory ACL)
EVIDENCE.md the Phase 4 proof transcript from the real machine
android/ kids' Android client (Gradle, Kotlin; Accessibility + overlay) android/ kids' Android client (Gradle, Kotlin; Accessibility + overlay)
app/src/main/java/… Accessibility Service, overlay, block rule, MQTT, tamper report app/src/main/java/… Accessibility Service, overlay, block rule, MQTT, tamper report
tools/emu-proof.sh headless emulator proof (block overlay + tamper) tools/emu-proof.sh headless emulator proof (block overlay + tamper)
@@ -625,14 +656,15 @@ docs/vikunja-tasks.json copy of the Vikunja board tasks
## Non-goals (Phase 10) ## Non-goals (Phase 10)
- **Phase 4, the Windows computer client, is not built.** It is deliberate. The - **The Windows client does not filter sites.** Blocking is by process only. There
Python client, process blocking, and the Windows overlay warning do not exist. is no per-process DNS attribution on Windows without a second dependency or an
The project stands at **9 of 10** because of this single gap. administrator-only channel, so the client publishes `"domains": []`; FR-18 site
filtering is satisfied on the phone only (`android/`). See `windows/README.md`.
- No grant button in the dashboard. Grant is on the admin API; the dashboard has - No grant button in the dashboard. Grant is on the admin API; the dashboard has
lock and unlock. Adding the button is a later UI change. lock and unlock. Adding the button is a later UI change.
- No per-child or per-device keycode. One code, every device (§10 rule 3). - No per-child or per-device keycode. One code, every device (§10 rule 3).
- No HTTP telemetry ingest. Telemetry arrives over MQTT only. The kids' phone - No HTTP telemetry ingest. Telemetry arrives over MQTT only. The kids' phone
client exists (Phase 5). The kids' computer client does not (Phase 4, deferred). client exists (Phase 5) and the kids' Windows client exists (Phase 4).
- No Alerts screen in the dashboard yet. The alert history is in `alerts` and is - No Alerts screen in the dashboard yet. The alert history is in `alerts` and is
readable with `control alerts`; the screen is a later UI change. readable with `control alerts`; the screen is a later UI change.
- The Hub is wired to this service: the running Hub on `.13:8788` routes - The Hub is wired to this service: the running Hub on `.13:8788` routes

199
windows/EVIDENCE.md Normal file
View File

@@ -0,0 +1,199 @@
# Phase 4 evidence — Windows client on the real machine
**Date:** 2026-10-14 (Australia/Sydney, UTC+11)
**Machine:** `DESKTOP-TE2RHV2` (`192.168.20.31`), account `New Comp` (local administrator, task
runs with a limited interactive token)
**Client version:** `webrestrict-windows 1.0.0-phase4` (`windows/`)
**Device:** `Finn's computer`, Control Service device id 2, topic `restrict/finn_s_computer_2/config`
**Broker:** Mosquitto on `.13:8883`, TLS, account `finn_s_computer_2` (topic ACL, `%u`)
**Control Service:** `.13:8791`, dashboard `https://webrestrict.lab.audasmedia.com.au`
The Definition of Done (AGENTS.md §11 Phase 4):
1. a real Windows machine blocks a listed process during a scheduled window;
2. the client keeps its last state when the broker is unavailable.
Both are shown below from the machine's own log. The client was installed as a Scheduled Task
and started through that task, so the evidence is produced by the deployment, not by a
hand-run interpreter.
---
## 1. The configuration that caused the block
Set through the dashboard (device 2): blocklist entry `notepad.exe`, and a temporary weekly
window for Wednesday 14:00–23:59. The Control Service published the retained config, read back
from the broker with the device's own account:
```json
{"v":1,"device_id":2,"name":"Finn's computer","platform":"windows","locked":true,
"until":"2026-10-14T23:59:00+11:00","allow":[],"block":["notepad.exe"],
"reason":"schedule","generated_at":"2026-10-14T03:49:31.401827999Z"}
```
## 2. A listed process is killed inside the scheduled window
Before the client starts, the process is running on the interactive desktop:
```
Image Name PID Session Name Session# Mem Usage
========================= ======== ================ =========== ============
notepad.exe 16600 Console 2 13,396 K
INFO: No tasks are running which match the specified criteria. <- pythonw.exe: client not running
```
The Scheduled Task is started (`schtasks /Run /TN "WebRestrict Kids Client"`). Machine log
`C:\ProgramData\WebRestrict\state\client.log`:
```
2026-10-14 14:59:46,301 INFO webrestrict no cached config yet; seeded an unlocked config (FR-14)
2026-10-14 14:59:46,301 INFO webrestrict startup config locked=False until=None block=0 allow=0 cached_at=None
2026-10-14 14:59:46,414 INFO webrestrict.overlay warning window is available
2026-10-14 14:59:46,498 INFO webrestrict.mqtt mqtt connecting to 192.168.20.13:8883 as finn_s_computer_2 (topic restrict/finn_s_computer_2/config)
2026-10-14 14:59:46,500 INFO webrestrict client running: poll=5s telemetry=30s status=30s device=finn_s_computer_2 state_dir=C:\ProgramData\WebRestrict\state
2026-10-14 14:59:47,131 INFO webrestrict.mqtt mqtt connected, subscribing to restrict/finn_s_computer_2/config
2026-10-14 14:59:47,131 INFO webrestrict link up: publishing presence and today's usage
2026-10-14 14:59:47,140 INFO webrestrict config applied locked=True until=2026-10-14T23:59:00+11:00 reason=schedule block=['notepad.exe'] allow=[] generated_at=2026-10-14T03:49:31.401827999Z
2026-10-14 14:59:47,169 INFO webrestrict blocked notepad.exe (pid=16600, rule=notepad): terminated
2026-10-14 14:59:47,169 INFO webrestrict enforcement killed 1 process(es): notepad.exe(16600)
```
After, on the same machine:
```
INFO: No tasks are running which match the specified criteria. <- notepad.exe, gone
Image Name PID Session Name Session# Mem Usage
pythonw.exe 12608 Console 2 47,904 K
```
The cached config on disk, which is what a broker-less restart uses:
```json
{"allow": [], "block": ["notepad.exe"], "device_id": 2,
"generated_at": "2026-10-14T03:49:31.401827999Z", "grant_until": null, "locked": true,
"name": "Finn's computer", "platform": "windows", "reason": "schedule",
"until": "2026-10-14T23:59:00+11:00", "v": 1}
```
## 3. The dashboard shows the device online with the right state
`GET /api/v1/admin/devices/2` on `.13:8791` while the client was running:
```json
{"ok":true,"device":{"id":2,"name":"Finn's computer","child":"Finn","platform":"windows",
"topic":"restrict/finn_s_computer_2/config","online":true,"last_seen":"2026-10-14T04:01:17Z",
"locked":true,"until":"2026-10-14T23:59:00+11:00","grant_until":null,"reason":"schedule"}}
```
`online: true` comes from the client's retained status report; `locked`/`until`/`reason` are the
schedule that is in force. The machine log shows the presence heartbeat:
```
2026-10-14 15:00:17,228 INFO webrestrict status published locked=True blocking=True broker=connected cached_config=True
2026-10-14 15:00:47,323 INFO webrestrict telemetry day=2026-10-14 apps=10 top=[{"name": "ApplicationFrameHost.exe", "seconds": 30}, ...]
2026-10-14 15:01:17,417 INFO webrestrict telemetry day=2026-10-14 apps=10 top=[{"name": "ApplicationFrameHost.exe", "seconds": 60}, ...]
```
## 4. A telemetry row lands in the Control Service
Control Service, same day (`docker exec webrestrict-control /app/control usage`):
```
day=2026-10-14 source=rollup
Harry's phone Harry app=5466 domain=4920 screen=5466
Finn's computer Finn app=690 domain=0 screen=690
total_screen=6156
```
`app=690` is `Finn's computer`'s daily total in seconds, from the client's
`restrict/finn_s_computer_2/telemetry` reports. `domain=0` is the documented gap: Windows has no
cheap per-process DNS attribution, so the client sends `"domains": []` instead of a guess (see
`README.md`).
The on-device roll-up shows the attribution, and confirms the client no longer credits itself
(the overlay's own window is excluded):
```json
{"days": {"2026-10-14": {"ApplicationFrameHost.exe": 60, "Discord.exe": 60, "EADesktop.exe": 60,
"Overwolf.exe": 60, "Spotify.exe": 60, "TextInputHost.exe": 60, "chrome.exe": 60, "cmd.exe": 60,
"explorer.exe": 60, "powershell.exe": 60}}}
```
## 5. Keep last state when the broker is unavailable (FR-14)
The client was stopped, then started again with a copy of the config whose broker port points at
a dead listener (`192.168.20.13:8884`) and the **same** state directory, so it loads the cached
config. A new `notepad.exe` was started afterwards. Machine log:
```
2026-10-14 15:01:41,347 INFO webrestrict startup config locked=True until=2026-10-14T23:59:00+11:00 block=1 allow=0 cached_at=2026-10-14T03:49:31.401827999Z
2026-10-14 15:01:41,539 INFO webrestrict.mqtt mqtt connecting to 192.168.20.13:8884 as finn_s_computer_2 (topic restrict/finn_s_computer_2/config)
2026-10-14 15:01:47,045 WARNING webrestrict.mqtt broker 192.168.20.13:8884 is unreachable; enforcing the cached config only (FR-14)
2026-10-14 15:01:47,045 WARNING webrestrict broker link lost (broker unreachable): enforcing the cached config (FR-14) block=['notepad.exe'] until=2026-10-14T23:59:00+11:00
2026-10-14 15:01:52,207 INFO webrestrict blocked notepad.exe (pid=9724, rule=notepad): terminated
```
`notepad.exe` (PID 9724) was running in session 2 immediately before that line, and
`tasklist /FI "IMAGENAME eq notepad.exe"` reported no matching task afterwards. The 8884 listener
does not exist, so nothing was received from the broker during that run.
The same resilience covers usage: on the next connected start the client republished the day's
totals held on disk (`telemetry republished day=2026-10-14 apps=10`).
## 6. The warning window (FR-9)
The client runs in the child's session (`pythonw.exe`, session 2) and shows an always-on-top
Tkinter window. A window enumeration from the same session, while a block was in force:
```json
{"hwnd": 3278938, "visible": true, "titlelen": 11, "title": "Screen time", "pid": 9908,
"class": "TkTopLevel", "proc": "pythonw.exe"}
```
Screenshot of the interactive desktop taken by a helper task in the same session:
`screen-offline-run.png` (attached to AUD-97). The bottom-right region of the image is a large
light-grey panel over a dark desktop — the overlay, placed bottom-right of the primary screen.
## 7. The Scheduled Task
```
Folder: \
HostName: DESKTOP-TE2RHV2
TaskName: \WebRestrict Kids Client
Status: Ready
Logon Mode: Interactive only
Run As User: New Comp
Task To Run: C:\Users\New Comp\AppData\Local\Programs\Python\Python314\pythonw.exe -m webrestrict --config "C:\ProgramData\WebRestrict\client.json"
Start In: C:\ProgramData\WebRestrict\client
Schedule Type: At logon time
```
Installed by `install-scheduled-task.ps1` (which also grants the account Modify on the state
directory), or by the one-line `schtasks /Create /TN "WebRestrict Kids Client" /SC ONLOGON /F ...`
form in `README.md`.
Every step above was produced by starting this task (`schtasks /Run`), not by a shell.
## 8. What was not proven, and the honest gaps
* **Domains in telemetry.** Not implemented on Windows, by design and by decision. The client
publishes `"domains": []`; there is no FR-18 site filter for Windows in this phase.
* **Blocking another account's processes.** The task runs with a limited (non-elevated) token, so
it blocks the child's own processes. A process owned by a different Windows account would need
the task to run at highest privilege.
* **Tamper detection is presence, not a watchdog.** A force-stop or a logoff drives the broker's
Last Will to `{"online": false}`, which the Control Service turns into the offline alert
(FR-13). There is no separate on-device watchdog process; a determined child can end
`pythonw.exe` and is visible as an offline device seconds later.
* **A process started mid-window is killed on the next tick** (≤ `poll_seconds`, 5 s by default).
## 9. End state left on the machine
* `WebRestrict Kids Client` is registered (at logon, `New Comp`, interactive) and running.
* The temporary proof window (Wednesday 14:00–23:59) was **cleared**; no invented schedule is in
force. `notepad.exe` stays on the blocklist, so the rule is ready for the next window — it does
nothing while the device is unlocked.
* Proof scaffolding (helper tasks, helper scripts, the offline config copy, screenshots) was
removed from the machine. Only `C:\ProgramData\WebRestrict\{client,client.json,ca.crt,state}`
remains.
* Final device state: `online: true`, `locked: false`, `reason: ""`.

231
windows/README.md Normal file
View File

@@ -0,0 +1,231 @@
# Kids' Windows computer client — Phase 4
The kids' **Windows** client (AGENTS.md §11 Phase 4, FR-3, FR-4, FR-6, FR-7,
FR-9, FR-14). Python, one process, no service install and no admin requirement
beyond what the account already has. It follows the Control Service's desired
state over MQTT, blocks listed processes while the device is locked, warns the
child, reports usage, and **keeps its last state when the broker is
unreachable**.
It is a device, not a control plane: it never changes its own rules (AGENTS.md
§10 rule 7). Everything it enforces arrives on `restrict/<device>/config`.
## What it does
| Piece | File | Job |
|---|---|---|
| Config transport | `webrestrict/mqtt_link.py` | Subscribes, retained, to `restrict/<device>/config` on the 8883 TLS plane with the device's own credentials; publishes presence and usage on `restrict/<device>/status` and `restrict/<device>/telemetry`. |
| Desired state | `webrestrict/config.py` | Parses the Phase 3 payload and computes the effective lock (`locked` and the clock). |
| Last state (FR-14) | `webrestrict/config.py` | The last valid config is cached on disk; a broker outage changes nothing, and only a newer valid config replaces it. |
| Block rule | `webrestrict/blocker.py` | Blocked = effectively locked **and** on the blocklist **and** not allowlisted (FR-4). |
| Process blocking (FR-3) | `webrestrict/process_watch.py` | Polls `psutil`, `terminate()`s a blocked process, escalates to `kill()` after a short grace. |
| Warning (FR-9) | `webrestrict/overlay.py` | An always-on-top Tkinter window: what is blocked, why, until when. |
| Usage (FR-7) | `webrestrict/telemetry.py` | Daily per-app seconds, rolled up on disk and published as day totals. |
| The loop | `webrestrict/agent.py` | Wires the pieces together with bounded, isolated ticks. |
| Install | `install-scheduled-task.ps1` | The logon Scheduled Task. |
**No service, no driver, no filter at the network edge.** The child can end the
process; that is accepted (see "Honest limits").
## How the block works
Every `poll_seconds` (default 5) the enforcer takes a process snapshot and
selects victims:
1. the cached config is **not** effectively locked → nothing is blocked;
2. a process is a victim when its normalised name is on the blocklist;
3. the **allowlist always wins** (FR-4) — `spotify.exe` stays up while everything
else is blocked;
4. the client never kills itself, its parent chain, or a protected system
process (`explorer.exe`, `lsass.exe`, `svchost.exe`, …), whatever a blocklist
says. The full default list is `DEFAULT_PROTECTED` in
`webrestrict/settings.py`.
Name matching is case-insensitive and ignores `.exe`, so `Notepad.EXE`,
`notepad.exe` and `notepad` are the same app.
"Effectively locked" mirrors `internal/store/schedules.go` `EffectiveState`: the
payload already carries the resolved `locked` flag, so the client only applies
the clock. An expired `until` lifts the block at the same instant the Control
Service would clear it, a live `grant_until` never blocks (FR-10 grant), and an
indefinite lock (`until` absent) never lifts.
## Keep last state (FR-14)
The config is cached at `<state_dir>/config-cache.json`, written atomically. The
client loads it **before** it tries to connect, so a device that never reaches
the broker still applies the rules it was last given. A malformed payload, a
newer schema version, or an older `generated_at` is rejected and the cache is
left alone.
## Presence (FR-6)
The client publishes a retained `{"online": true}` status when it connects and
registers the will `{"online": false}` on the same topic. If the machine is
switched off, the client is force-stopped, or the link dies, the broker
publishes the will and the Control Service marks the device offline — that is
the Windows tamper/offline signal (FR-17 here is "the client is gone", which
`CONTROL_ALERT_OFFLINE_AFTER` turns into an Apprise alert, FR-13). A clean stop
publishes the offline status explicitly.
The status payload also carries `locked` and the foreground `app`, so the
dashboard shows real state:
```json
{"app":"notepad.exe","at":"2026-10-14T04:05:06Z","locked":true,"online":true}
```
## Usage telemetry (FR-7), and the domain gap
Windows has no `UsageStatsManager`. The client builds its own daily total: every
`telemetry_seconds` (default 60) it lists the processes that own a **visible,
titled top-level window** and adds the elapsed seconds to each. The roll-up is
persisted per day (14 days kept) and published as **day totals**, so a repeated
report replaces the Control Service row instead of double counting
(`internal/store/migrations/002_telemetry_unique.sql`):
```json
{"day":"2026-10-14","apps":[{"name":"notepad.exe","seconds":420}],"domains":[]}
```
* It counts "an app had a window on screen", sampled every minute, not Android's
exact foreground clock. It is an approximation, and it is the honest one
available without a second dependency. The client's own process is excluded,
so its warning window is never counted as the child's usage.
* **Domains are always `[]`.** Windows gives no cheap per-process DNS
attribution: it needs a packet capture, an ETW consumer, or the DNS Client
event log — a second dependency or an administrator-only channel. The gap is
recorded here rather than filled with a guess. Site filtering for Windows is
therefore **not** implemented in this phase; FR-18 is satisfied on the phone
only (`android/`). Say so in any status report.
## Safety limits
* Never kills a protected system process, the current process tree, or anything
on the allowlist.
* A missing or corrupt config never invents a lock: first run seeds an
**unlocked** config, so a fresh install cannot lock a child out of their
machine while the broker is unreachable.
* Every tick is wrapped: a psutil or Tk failure is logged and the loop
continues.
## Honest limits
* The child can end the process (`taskkill /im pythonw.exe`). The enforcement is
the tamper/offline signal and the parent's knowledge, not a fortress
(AGENTS.md §4 FR-17 rationale, TRANSPORT_DESIGN.md "Anti-bypass").
* A process started **after** a tick is killed on the next tick; the window is
`poll_seconds` (5 s by default).
* The overlay is a warning only. Closing it does not lift the block.
* Running the task as a limited (non-elevated) user blocks the child's own
processes, which is the normal case. Blocking a process owned by **another**
Windows account needs the task to run at highest privilege.
## Provisioning a machine
Nothing secret is committed. The config file holds the device's MQTT password.
1. **Register the device** in the Control Service (dashboard or admin API) with
platform `windows`, then read its topic token:
```bash
docker exec webrestrict-control /app/control mqtt-clients
```
2. **Create its broker account** on the 8883 plane and read the password from
the generated file (both gitignored):
```bash
deploy/mosquitto/provision-clients.sh
grep -P '^finn_s_computer_2\t' deploy/mosquitto/clients.env
```
3. **Copy the CA and the client** to the machine. `C:\ProgramData\WebRestrict`
is machine-wide and readable by the kid's account:
```powershell
New-Item -ItemType Directory -Force C:\ProgramData\WebRestrict\client
# copy windows\webrestrict\* to C:\ProgramData\WebRestrict\client\webrestrict\
# copy deploy/mosquitto/tls/ca.crt to C:\ProgramData\WebRestrict\ca.crt
python -m pip install --user -r requirements.txt
```
4. **Write `C:\ProgramData\WebRestrict\client.json`** (mode: readable by the kid's
account only) — `{ ` `}` replaced by the real values:
```json
{
"broker_host": "192.168.20.13",
"broker_port": 8883,
"ca_file": "C:\\ProgramData\\WebRestrict\\ca.crt",
"device_topic": "restrict/finn_s_computer_2/config",
"mqtt_username": "finn_s_computer_2",
"mqtt_password": "<from clients.env>",
"state_dir": "C:\\ProgramData\\WebRestrict\\state"
}
```
5. **Install the Scheduled Task** so the client survives a reboot. The script
also grants the account Modify on the state directory — a file created from
an elevated shell inherits only `Administrators`, and the task's limited
interactive token would otherwise get `Access denied`:
```powershell
powershell -ExecutionPolicy Bypass -File C:\ProgramData\WebRestrict\client\install-scheduled-task.ps1 `
-ClientDir "C:\ProgramData\WebRestrict\client" `
-Config "C:\ProgramData\WebRestrict\client.json"
```
Or, the one-line form:
```
schtasks /Create /TN "WebRestrict Kids Client" /SC ONLOGON /F ^
/TR "\"C:\Users\New Comp\AppData\Local\Programs\Python\Python314\pythonw.exe\" -m webrestrict --config \"C:\ProgramData\WebRestrict\client.json\""
```
Both register the task for the account that runs them, with an interactive
logon token and no stored password, and both start the client at logon.
6. **Check it without waiting**:
```
python -m webrestrict check
```
That prints the cached config, whether it is effectively locked, and the
processes it would terminate right now. It changes nothing.
## Running and reading the log
```
python -m webrestrict --config C:\ProgramData\WebRestrict\client.json # the client
python -m webrestrict check # one-shot state
python -m webrestrict version
```
The client logs to the console and to `<state_dir>/client.log` (2 MB, 3
rotations). It records every config it applied and every process it blocked,
which is the evidence trail for a parent and for a phase check.
## Tests
Pure logic only — no broker, no Windows, no psutil:
```bash
cd windows && python -m unittest discover -s tests -t .
# or, from the repository root:
make windows-test
```
53 tests cover the payload shape, the effective lock, name matching, the
allowlist, the keep-last-state cache, victim selection (including the protected
names), the usage roll-up, and the status and telemetry payloads.
## Evidence from the live run
See `windows/EVIDENCE.md` for the transcript of the Definition of Done on the
real machine (`DESKTOP-TE2RHV2`): a listed process killed inside a scheduled
window, the dashboard showing the device online, a telemetry row landing in the
Control Service, enforcement continuing with the broker unreachable, the warning
window on screen, and the Scheduled Task registered at logon. It also records
the honest gaps (no domain attribution, no elevated blocking, tamper = offline).

View File

@@ -0,0 +1,90 @@
# Install (or remove) the Internet Use Control kids' client as a Scheduled Task
# that starts at logon (AGENTS.md §11 Phase 4).
#
# The task runs as the signed-in kid's account, in that account's interactive
# session, which is where the child's apps and the warning window live. No
# password is stored: the task uses an interactive logon token, so it starts
# when the child logs on and the child cannot see a service window.
#
# powershell -ExecutionPolicy Bypass -File install-scheduled-task.ps1 `
# -ClientDir "C:\ProgramData\WebRestrict\client" `
# -Config "C:\ProgramData\WebRestrict\client.json"
#
# Remove it again with:
#
# powershell -ExecutionPolicy Bypass -File install-scheduled-task.ps1 -Remove
#
# The equivalent one-line schtasks form (what the phase proof used) is:
#
# schtasks /Create /TN "WebRestrict Kids Client" /SC ONLOGON /F ^
# /TR "\"<pythonw.exe>\" -m webrestrict --config \"C:\ProgramData\WebRestrict\client.json\""
[CmdletBinding()]
param(
[string]$TaskName = "WebRestrict Kids Client",
[string]$Account = "$env:USERDOMAIN\$env:USERNAME",
[string]$Config = "$env:ProgramData\WebRestrict\client.json",
[string]$ClientDir = "$env:ProgramData\WebRestrict\client",
[string]$StateDir = "$env:ProgramData\WebRestrict\state",
[string]$Python = "",
[switch]$SkipAcl,
[switch]$Remove
)
$ErrorActionPreference = "Stop"
if ($Remove) {
schtasks /Delete /TN $TaskName /F
Write-Host "removed scheduled task '$TaskName'"
exit 0
}
# The state directory holds the log, the cached config and the usage roll-up.
# The task runs with a limited interactive token, so the account needs an
# explicit Modify grant: a file created from an elevated shell inherits only
# Administrators, and the limited task is then refused with "Access denied".
if (-not $SkipAcl) {
if (-not (Test-Path $StateDir)) { New-Item -ItemType Directory -Force -Path $StateDir | Out-Null }
icacls $StateDir /grant "${Account}:(OI)(CI)M" /T /C | Out-Null
Write-Host "granted Modify on $StateDir to $Account"
}
function Resolve-Python {
param([string]$Explicit)
if ($Explicit) { return $Explicit }
foreach ($candidate in @("pythonw.exe", "python.exe")) {
$found = Get-Command $candidate -ErrorAction SilentlyContinue
if ($found) {
$path = $found.Source
# pythonw.exe has no console, so a logon start shows no window.
$windowless = Join-Path (Split-Path $path) "pythonw.exe"
if (Test-Path $windowless) { return $windowless }
return $path
}
}
throw "python was not found on PATH; pass -Python <path to pythonw.exe>"
}
$pythonExe = Resolve-Python -Explicit $Python
$arguments = "-m webrestrict --config `"$Config`""
$action = New-ScheduledTaskAction -Execute $pythonExe -Argument $arguments -WorkingDirectory $ClientDir
$trigger = New-ScheduledTaskTrigger -AtLogOn -User $Account
$settings = New-ScheduledTaskSettingsSet `
-AllowStartIfOnBatteries `
-DontStopIfGoingOnBatteries `
-StartWhenAvailable `
-RestartCount 3 `
-RestartInterval (New-TimeSpan -Minutes 1) `
-ExecutionTimeLimit (New-TimeSpan -Seconds 0) `
-MultipleInstances IgnoreNew
$principal = New-ScheduledTaskPrincipal -UserId $Account -LogonType Interactive -RunLevel Limited
Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $trigger `
-Settings $settings -Principal $principal -Force | Out-Null
Write-Host "registered '$TaskName'"
Write-Host " account : $Account (interactive, at logon)"
Write-Host " command : $pythonExe $arguments"
Write-Host " workdir : $ClientDir"
schtasks /Query /TN $TaskName /V /FO LIST

9
windows/requirements.txt Normal file
View File

@@ -0,0 +1,9 @@
# Windows kids' client dependencies (AGENTS.md §11 Phase 4).
#
# psutil — enumerate and terminate processes.
# paho-mqtt — the MQTT TLS link to the 8883 device plane.
#
# tkinter (the overlay warning) ships with the python.org Windows installer and
# is deliberately not a pip dependency.
paho-mqtt>=2.1,<3
psutil>=5.9

View File

@@ -0,0 +1,9 @@
"""Unit tests for the kids' Windows client (AGENTS.md §11 Phase 4).
Pure logic only: no broker, no Windows, no psutil. The tests run anywhere with
``python -m unittest`` so the block decision cannot regress unnoticed::
cd windows && python -m unittest discover -s tests -t .
"""
from __future__ import annotations

View File

@@ -0,0 +1,79 @@
"""Block selection: what the enforcer may and may not kill."""
from __future__ import annotations
import unittest
from datetime import datetime, timedelta, timezone
from webrestrict.blocker import select_victims, self_protection
from webrestrict.config import DeviceConfig
NOW = datetime(2026, 10, 14, 20, 0, tzinfo=timezone.utc)
def config(**overrides) -> DeviceConfig:
base = dict(
locked=True,
until=(NOW + timedelta(minutes=30)).isoformat(),
block=["notepad.exe", "steam.exe"],
allow=["Spotify.exe"],
)
base.update(overrides)
return DeviceConfig(**base)
PROCESSES = [
(100, "notepad.exe"),
(101, "Notepad.EXE"),
(200, "steam.exe"),
(300, "Spotify.exe"),
(400, "explorer.exe"),
(500, "python.exe"),
]
class SelectVictimsTests(unittest.TestCase):
def test_blocks_every_matching_process(self):
victims = select_victims(PROCESSES, config())
self.assertEqual([(v.pid, v.matched) for v in victims], [(100, "notepad"), (101, "notepad"), (200, "steam")])
def test_allowlist_wins(self):
victims = select_victims(PROCESSES, config(allow=["spotify"]))
self.assertNotIn(300, [v.pid for v in victims])
def test_nothing_is_blocked_when_unlocked(self):
self.assertEqual(select_victims(PROCESSES, config(locked=False)), [])
def test_nothing_is_blocked_when_the_blocklist_is_empty(self):
self.assertEqual(select_victims(PROCESSES, config(block=[])), [])
def test_nothing_is_blocked_after_until(self):
expired = config(until=(NOW - timedelta(minutes=1)).isoformat())
self.assertEqual(select_victims(PROCESSES, expired, now=NOW), [])
def test_protected_names_are_never_victims(self):
victims = select_victims(PROCESSES, config(block=["explorer.exe"]), protected_names=["explorer.exe"])
self.assertEqual(victims, [])
def test_protected_pids_are_never_victims(self):
victims = select_victims(PROCESSES, config(block=["python.exe"]), protected_pids=[500])
self.assertEqual(victims, [])
def test_duplicate_pids_are_reported_once(self):
victims = select_victims([(100, "notepad.exe"), (100, "notepad.exe")], config())
self.assertEqual(len(victims), 1)
def test_bad_rows_are_ignored(self):
victims = select_victims([("x", "notepad.exe"), (0, ""), (None, None)], config())
self.assertEqual(victims, [])
class SelfProtectionTests(unittest.TestCase):
def test_includes_the_process_and_its_parents(self):
pids, names = self_protection(10, ["python.exe", "pythonw.exe"], [5, 3, 1])
self.assertEqual(pids, {10, 5, 3, 1})
self.assertEqual(names, {"python.exe", "pythonw.exe"})
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,149 @@
"""Config parsing, effective lock state and the keep-last-state cache."""
from __future__ import annotations
import json
import tempfile
import unittest
from datetime import datetime, timedelta, timezone
from pathlib import Path
from webrestrict.config import ConfigCache, DeviceConfig, bare, parse_time
NOW = datetime(2026, 10, 14, 20, 0, tzinfo=timezone.utc)
def payload(**overrides) -> str:
body = {
"v": 1,
"device_id": 2,
"name": "Finn's computer",
"platform": "windows",
"locked": True,
"until": (NOW + timedelta(minutes=30)).isoformat().replace("+00:00", "Z"),
"allow": ["Spotify.exe"],
"block": ["notepad.exe", "steam"],
"reason": "schedule",
"generated_at": "2026-10-14T19:00:00Z",
}
body.update(overrides)
return json.dumps(body)
class ParseTests(unittest.TestCase):
def test_parses_the_phase3_shape(self):
config = DeviceConfig.from_json(payload(extra_field="ignored"))
self.assertEqual(config.v, 1)
self.assertEqual(config.device_id, 2)
self.assertTrue(config.locked)
self.assertEqual(config.allow, ["Spotify.exe"])
self.assertEqual(config.block, ["notepad.exe", "steam"])
self.assertEqual(config.reason, "schedule")
self.assertIsNotNone(config.until_at())
def test_missing_lists_become_empty_arrays(self):
config = DeviceConfig.from_json('{"v":1,"locked":true}')
self.assertEqual(config.allow, [])
self.assertEqual(config.block, [])
def test_rejects_a_newer_schema(self):
with self.assertRaises(ValueError):
DeviceConfig.from_json(payload(v=2))
def test_rejects_malformed_json(self):
with self.assertRaises(json.JSONDecodeError):
DeviceConfig.from_json("{not json")
def test_parse_time_accepts_z_and_offsets(self):
self.assertEqual(parse_time("2026-10-14T20:00:00Z").tzinfo, timezone.utc)
self.assertEqual(parse_time("2026-10-14T20:00:00+10:00").utcoffset(), timedelta(hours=10))
self.assertIsNone(parse_time("nonsense"))
self.assertIsNone(parse_time(None))
class EffectiveStateTests(unittest.TestCase):
def test_not_locked_never_blocks(self):
config = DeviceConfig.from_json(payload(locked=False))
self.assertFalse(config.is_blocking(NOW))
def test_locked_without_until_blocks_indefinitely(self):
config = DeviceConfig.from_json(payload(until=None))
self.assertTrue(config.is_blocking(NOW))
self.assertTrue(config.is_blocking(NOW + timedelta(days=30)))
def test_locked_until_a_future_time_blocks(self):
config = DeviceConfig.from_json(payload())
self.assertTrue(config.is_blocking(NOW))
def test_locked_until_a_past_time_does_not_block(self):
config = DeviceConfig.from_json(payload(until=(NOW - timedelta(minutes=1)).isoformat()))
self.assertFalse(config.is_blocking(NOW))
def test_a_live_grant_never_blocks(self):
config = DeviceConfig.from_json(
payload(locked=False, until=None, grant_until=(NOW + timedelta(minutes=10)).isoformat())
)
self.assertFalse(config.is_blocking(NOW))
def test_an_expired_grant_does_not_block_either(self):
config = DeviceConfig.from_json(
payload(locked=False, until=None, grant_until=(NOW - timedelta(minutes=10)).isoformat())
)
self.assertFalse(config.is_blocking(NOW))
class NameTests(unittest.TestCase):
def test_bare_normalises_case_and_exe(self):
self.assertEqual(bare("Notepad.EXE"), "notepad")
self.assertEqual(bare(" STEAM.exe "), "steam")
self.assertEqual(bare(None), "")
def test_blocked_names_subtract_the_allowlist(self):
config = DeviceConfig.from_json(payload(block=["notepad.exe", "Spotify"], allow=["spotify.EXE"]))
self.assertEqual(config.blocked_names(), {"notepad"})
def test_allowlist_wins_per_name(self):
config = DeviceConfig.from_json(payload(block=["notepad.exe", "steam.exe"], allow=["Notepad"]))
self.assertFalse(config.is_blocked_name("NOTEPAD.EXE"))
self.assertTrue(config.is_blocked_name("steam.exe"))
class CacheTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.path = Path(self.tmp.name) / "config-cache.json"
self.addCleanup(self.tmp.cleanup)
def test_round_trips_through_disk(self):
cache = ConfigCache(self.path)
stored = cache.accept(payload())
self.assertIsNotNone(stored)
again = ConfigCache(self.path).load()
self.assertEqual(again.block, stored.block)
self.assertTrue(again.locked)
def test_a_malformed_payload_keeps_the_last_config(self):
cache = ConfigCache(self.path)
cache.accept(payload())
self.assertIsNone(cache.accept("{not json"))
self.assertEqual(cache.current.block, ["notepad.exe", "steam"])
def test_an_older_config_is_ignored(self):
cache = ConfigCache(self.path)
cache.accept(payload(generated_at="2026-10-14T19:30:00Z", block=["steam.exe"]))
self.assertIsNone(cache.accept(payload(generated_at="2026-10-14T18:00:00Z", block=["notepad.exe"])))
self.assertEqual(cache.current.block, ["steam.exe"])
def test_a_newer_config_replaces_it(self):
cache = ConfigCache(self.path)
cache.accept(payload(generated_at="2026-10-14T19:30:00Z", block=["steam.exe"]))
stored = cache.accept(payload(generated_at="2026-10-14T19:45:00Z", block=["notepad.exe"]))
self.assertIsNotNone(stored)
self.assertEqual(cache.current.block, ["notepad.exe"])
def test_a_missing_file_is_not_an_error(self):
self.assertIsNone(ConfigCache(self.path).load())
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,102 @@
"""Settings loading and the MQTT payload builders (no broker, no paho)."""
from __future__ import annotations
import json
import os
import tempfile
import unittest
from pathlib import Path
from webrestrict.mqtt_link import iso_now, status_payload, telemetry_payload
from webrestrict.settings import DEFAULT_PROTECTED, Settings
RAW = {
"broker_host": "192.168.20.13",
"broker_port": 8883,
"device_topic": "restrict/finn_s_computer_2/config",
"mqtt_username": "finn_s_computer_2",
"mqtt_password": "secret",
"ca_file": "C:/ProgramData/WebRestrict/ca.crt",
"state_dir": "C:/ProgramData/WebRestrict",
"unknown_future_field": "ignored",
}
class SettingsTests(unittest.TestCase):
def test_derives_topics_and_token(self):
settings = Settings.from_dict(RAW)
self.assertEqual(settings.device_token, "finn_s_computer_2")
self.assertEqual(settings.status_topic, "restrict/finn_s_computer_2/status")
self.assertEqual(settings.telemetry_topic, "restrict/finn_s_computer_2/telemetry")
self.assertEqual(settings.effective_client_id, "kid-finn_s_computer_2")
def test_unknown_fields_are_ignored(self):
settings = Settings.from_dict(RAW)
self.assertFalse(hasattr(settings, "unknown_future_field"))
def test_loads_from_a_file(self):
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "client.json"
path.write_text(json.dumps({**RAW, "state_dir": tmp}), encoding="utf-8")
settings = Settings.load(path)
self.assertEqual(settings.broker_port, 8883)
self.assertEqual(settings.log_path(), Path(tmp) / "client.log")
def test_validate_rejects_a_bad_topic(self):
settings = Settings.from_dict({**RAW, "device_topic": "restrict/finn"})
with self.assertRaises(ValueError):
settings.validate()
def test_validate_rejects_a_missing_account(self):
bad = {k: v for k, v in RAW.items() if k != "mqtt_username"}
with self.assertRaises(ValueError):
Settings.from_dict(bad).validate()
def test_validate_rejects_a_bad_port(self):
with self.assertRaises(ValueError):
Settings.from_dict({**RAW, "broker_port": 70000}).validate()
def test_protected_list_covers_the_system_and_the_interpreter(self):
for name in ("explorer", "svchost", "lsass", "python"):
self.assertIn(name, DEFAULT_PROTECTED)
def test_the_default_config_path_is_overridable(self):
from webrestrict.settings import default_config_path
previous = os.environ.get("WEBRESTRICT_CONFIG")
os.environ["WEBRESTRICT_CONFIG"] = "/tmp/webrestrict-test.json"
try:
self.assertEqual(str(default_config_path()), "/tmp/webrestrict-test.json")
finally:
if previous is None:
os.environ.pop("WEBRESTRICT_CONFIG", None)
else:
os.environ["WEBRESTRICT_CONFIG"] = previous
class PayloadTests(unittest.TestCase):
def test_status_reports_online_and_the_lock(self):
body = json.loads(status_payload(True, "notepad.exe"))
self.assertTrue(body["online"])
self.assertTrue(body["locked"])
self.assertEqual(body["app"], "notepad.exe")
self.assertTrue(body["at"].endswith("Z"))
def test_status_can_carry_a_tamper_report(self):
body = json.loads(status_payload(True, "", tamper={"kind": "client_stopped", "detail": "watchdog"}))
self.assertEqual(body["tamper"]["kind"], "client_stopped")
self.assertTrue(body["tamper"]["at"].endswith("Z"))
def test_telemetry_shape_is_the_list_form(self):
body = json.loads(telemetry_payload("2026-10-14", {"notepad.exe": 60, "steam.exe": 0}))
self.assertEqual(body["day"], "2026-10-14")
self.assertEqual(body["apps"], [{"name": "notepad.exe", "seconds": 60}])
self.assertEqual(body["domains"], [])
def test_iso_now_is_utc(self):
self.assertRegex(iso_now(), r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$")
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,115 @@
"""The usage roll-up: payload shape, accumulation, pruning and sampling."""
from __future__ import annotations
import json
import tempfile
import unittest
from datetime import datetime, timedelta, timezone
from pathlib import Path
from webrestrict.telemetry import (
MAX_DAYS,
MAX_SAMPLE_SECONDS,
TelemetryStore,
UsageSampler,
build_payload,
day_key,
to_entries,
)
NOW = datetime(2026, 10, 14, 20, 0, tzinfo=timezone.utc)
class PayloadTests(unittest.TestCase):
def test_shape_matches_the_control_service(self):
body = build_payload("2026-10-14", {"notepad.exe": 120, "steam.exe": 60})
self.assertEqual(body["day"], "2026-10-14")
self.assertEqual(body["apps"], [{"name": "notepad.exe", "seconds": 120}, {"name": "steam.exe", "seconds": 60}])
# Windows cannot attribute domains cheaply; the gap is explicit, not guessed.
self.assertEqual(body["domains"], [])
def test_zero_and_blank_entries_are_dropped(self):
self.assertEqual(to_entries({"": 10, "steam.exe": 0, "notepad.exe": 5}), [{"name": "notepad.exe", "seconds": 5}])
def test_day_key_is_local(self):
self.assertEqual(day_key(datetime(2026, 1, 2, 23, 59)), "2026-01-02")
def test_an_aware_timestamp_is_converted_to_local_time(self):
# The day boundary must be the child's midnight, not UTC's.
offset = datetime.now().astimezone().utcoffset()
utc = datetime(2026, 1, 2, 23, 30, tzinfo=timezone.utc)
self.assertEqual(day_key(utc), (utc + offset).strftime("%Y-%m-%d"))
class StoreTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.path = Path(self.tmp.name) / "telemetry.json"
self.addCleanup(self.tmp.cleanup)
def test_accumulates_and_persists(self):
store = TelemetryStore(self.path)
store.add_seconds("2026-10-14", {"notepad.exe": 60})
store.add_seconds("2026-10-14", {"notepad.exe": 30, "steam.exe": 10})
self.assertEqual(store.day("2026-10-14"), {"notepad.exe": 90, "steam.exe": 10})
reloaded = TelemetryStore(self.path)
self.assertEqual(reloaded.day("2026-10-14"), {"notepad.exe": 90, "steam.exe": 10})
def test_ignores_non_positive_samples(self):
store = TelemetryStore(self.path)
store.add_seconds("2026-10-14", {"notepad.exe": 0, "steam.exe": -5})
self.assertEqual(store.day("2026-10-14"), {})
def test_prunes_to_the_most_recent_days(self):
store = TelemetryStore(self.path)
for day in range(1, MAX_DAYS + 5):
store.add_seconds(f"2026-01-{day:02d}", {"notepad.exe": 1})
self.assertEqual(len(store.days), MAX_DAYS)
self.assertIn("2026-01-18", store.days)
self.assertNotIn("2026-01-01", store.days)
def test_an_unreadable_file_starts_empty(self):
self.path.write_text("{not json", encoding="utf-8")
store = TelemetryStore(self.path).load()
self.assertEqual(store.days, {})
def test_written_json_is_readable(self):
store = TelemetryStore(self.path)
store.add_seconds("2026-10-14", {"notepad.exe": 5})
self.assertIn("days", json.loads(self.path.read_text(encoding="utf-8")))
class SamplerTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.store = TelemetryStore(Path(self.tmp.name) / "telemetry.json")
self.addCleanup(self.tmp.cleanup)
def test_the_first_sample_only_starts_the_clock(self):
sampler = UsageSampler(self.store)
self.assertEqual(sampler.sample(["notepad.exe"], now=NOW, day="2026-10-14"), {})
def test_credits_elapsed_seconds_to_each_visible_app(self):
sampler = UsageSampler(self.store)
sampler.sample(["notepad.exe"], now=NOW, day="2026-10-14")
totals = sampler.sample(["notepad.exe", "steam.exe"], now=NOW + timedelta(seconds=60), day="2026-10-14")
self.assertEqual(totals, {"notepad.exe": 60, "steam.exe": 60})
def test_a_long_gap_is_capped(self):
sampler = UsageSampler(self.store)
sampler.sample(["notepad.exe"], now=NOW, day="2026-10-14")
totals = sampler.sample(
["notepad.exe"], now=NOW + timedelta(hours=5), day="2026-10-14"
)
self.assertEqual(totals["notepad.exe"], int(MAX_SAMPLE_SECONDS))
def test_no_visible_apps_adds_nothing(self):
sampler = UsageSampler(self.store)
sampler.sample([], now=NOW, day="2026-10-14")
self.assertEqual(sampler.sample([], now=NOW + timedelta(seconds=60), day="2026-10-14"), {})
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,12 @@
"""Kids' Windows computer client for Internet Use Control (AGENTS.md §11 Phase 4).
The client is a device, not a control plane. It subscribes to its own retained
config on the MQTT 8883 plane, blocks listed processes while the device is
locked, reports presence and daily usage, warns the child, and keeps enforcing
its last config when the broker is unreachable (FR-14). It never changes its own
rules (AGENTS.md §10 rule 7).
Run it as ``python -m webrestrict`` (see ``windows/README.md``).
"""
__version__ = "1.0.0-phase4"

View File

@@ -0,0 +1,96 @@
"""Command line entry point: ``python -m webrestrict`` (AGENTS.md §11 Phase 4).
Usage::
python -m webrestrict run # the client (the Scheduled Task runs this)
python -m webrestrict check # one-shot: cached config, effective state, victims
python -m webrestrict version
``--config PATH`` overrides the config file (default
``%PROGRAMDATA%\\WebRestrict\\client.json``, or ``WEBRESTRICT_CONFIG``).
"""
from __future__ import annotations
import argparse
import json
import logging
import logging.handlers
import sys
from . import __version__
from .agent import Agent, check_once, install_signal_handlers
from .settings import Settings, default_config_path
LOG_FORMAT = "%(asctime)s %(levelname)s %(name)s %(message)s"
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="webrestrict", description="Kids' Windows computer client")
parser.add_argument("--config", help="path to client.json (default: %(default)s)")
parser.add_argument("--verbose", action="store_true", help="debug logging")
sub = parser.add_subparsers(dest="command")
sub.add_parser("run", help="run the client (default)")
sub.add_parser("check", help="print the cached config and what would be blocked, then exit")
sub.add_parser("version", help="print the client version")
return parser
def configure_logging(settings: Settings, verbose: bool) -> None:
"""Log to the console and to a rotating file next to the state.
A Scheduled Task runs with no visible console, so the file is the record
that survives: it holds the config that arrived and every process blocked.
"""
root = logging.getLogger()
root.setLevel(logging.DEBUG if verbose else logging.INFO)
root.handlers.clear()
console = logging.StreamHandler(sys.stdout)
console.setFormatter(logging.Formatter(LOG_FORMAT))
root.addHandler(console)
try:
settings.state_path.mkdir(parents=True, exist_ok=True)
rotating = logging.handlers.RotatingFileHandler(
settings.log_path(), maxBytes=2 * 1024 * 1024, backupCount=3, encoding="utf-8"
)
rotating.setFormatter(logging.Formatter(LOG_FORMAT))
root.addHandler(rotating)
except OSError as exc: # pragma: no cover - depends on the machine
root.warning("cannot write the log file %s: %s", settings.log_path(), exc)
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
command = args.command or "run"
if command == "version":
print(f"webrestrict-windows {__version__}")
return 0
settings_path = args.config or str(default_config_path())
try:
settings = Settings.load(settings_path)
except (OSError, ValueError, json.JSONDecodeError) as exc:
print(f"webrestrict: cannot load {settings_path}: {exc}", file=sys.stderr)
return 2
configure_logging(settings, args.verbose)
if command == "check":
print(json.dumps(check_once(settings), indent=2, sort_keys=True))
return 0
agent = Agent(settings)
install_signal_handlers(agent)
try:
return agent.run()
except KeyboardInterrupt: # pragma: no cover - Ctrl-C
agent.shutdown()
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,317 @@
"""The client agent: one loop that enforces, warns, reports and never gives up.
Wiring, in one place:
* the cached config is loaded **before** the broker link is attempted, so the
device is enforcing the moment the process starts, broker or no broker
(FR-14);
* the MQTT link subscribes to the retained config and publishes presence;
* every few seconds the enforcer terminates blocked processes while the device
is effectively locked (FR-3, FR-4);
* the overlay shows the child what is blocked and until when (FR-9);
* every minute the usage sample is rolled up and published (FR-7), and the
status report keeps the dashboard's presence fresh (FR-6).
Nothing here changes the rules. The client obeys and reports (AGENTS.md §10
rule 7).
"""
from __future__ import annotations
import json
import logging
import signal
import threading
import time
from dataclasses import dataclass, field
from datetime import datetime, timezone
from .config import ConfigCache, DeviceConfig
from .mqtt_link import MqttLink, iso_now
from .overlay import Notice, Overlay
from .process_watch import EnforcementReport, Enforcer, foreground_app, visible_apps
from .settings import Settings
from .telemetry import TelemetryStore, UsageSampler, build_payload, day_key
log = logging.getLogger("webrestrict")
def format_until(config: DeviceConfig) -> str:
"""A short local rendering of the config's end time, for the log and overlay."""
for value in (config.grant_until, config.until):
if not value:
continue
from .config import parse_time # noqa: PLC0415 - small helper, local use
moment = parse_time(value)
return moment.astimezone().strftime("%a %H:%M") if moment else value
return ""
@dataclass
class AgentState:
"""The moving parts, so tests can drive them without a broker or a desktop."""
settings: Settings
cache: ConfigCache
config: DeviceConfig
telemetry: TelemetryStore
sampler: UsageSampler
overlay: Overlay
enforcer: Enforcer
link: MqttLink | None = None
last_report: EnforcementReport | None = None
started_at: float = field(default_factory=time.monotonic)
class Agent:
"""The long-running kids' client."""
def __init__(self, settings: Settings, logger: logging.Logger | None = None):
self.settings = settings
self.log = logger or log
self.stop_event = threading.Event()
telemetry = TelemetryStore(settings.telemetry_path())
self.state = AgentState(
settings=settings,
cache=ConfigCache(settings.config_path()),
config=DeviceConfig(),
telemetry=telemetry,
sampler=UsageSampler(telemetry),
overlay=Overlay(enabled=settings.overlay),
enforcer=Enforcer(settings.protected, self.log),
)
# -- setup -------------------------------------------------------------
def load_config(self) -> DeviceConfig:
"""Load the cached config, seeding a permissive first-run config.
The seed is **unlocked with no lists**: a client that has never heard
from the broker must never invent a lock. The first retained config
replaces it.
"""
cached = self.state.cache.load()
if cached is None:
cached = DeviceConfig(platform="windows", locked=False)
self.state.cache.save(cached)
self.log.info("no cached config yet; seeded an unlocked config (FR-14)")
self.state.config = cached
self.log.info(
"startup config locked=%s until=%s block=%d allow=%d cached_at=%s",
cached.locked,
cached.until,
len(cached.block),
len(cached.allow),
cached.generated_at,
)
return cached
# -- callbacks ---------------------------------------------------------
def apply_config(self, payload: str) -> None:
"""Handle a config message: store it if valid, then apply it at once."""
accepted = self.state.cache.accept(payload)
if accepted is None:
self.log.warning("config ignored (older or unreadable); keeping the cached one")
return
self.state.config = accepted
self.log.info(
"config applied locked=%s until=%s reason=%s block=%s allow=%s generated_at=%s",
accepted.locked,
accepted.until,
accepted.reason or "-",
accepted.block,
accepted.allow,
accepted.generated_at,
)
# Act on it now instead of waiting for the next tick: a lock must land
# within the poll interval, and the Android client behaves the same way.
self.tick()
def on_connected(self) -> None:
config = self.state.config
self.log.info("link up: publishing presence and today's usage")
self.publish_status(config)
self.flush_telemetry()
def on_lost(self, reason: str) -> None:
self.log.warning(
"broker link lost (%s): enforcing the cached config (FR-14) block=%s until=%s",
reason,
self.state.config.block,
self.state.config.until,
)
# -- one tick ----------------------------------------------------------
def tick(self) -> EnforcementReport:
config = self.state.config
now = datetime.now(timezone.utc)
blocking = config.is_blocking(now)
report = self.state.enforcer.enforce(config, now)
self.state.last_report = report
self.state.overlay.update(self.notice(config, report, now) if blocking else None)
return report
def notice(self, config: DeviceConfig, report: EnforcementReport, now: datetime) -> Notice | None:
if not report.blocked_names:
return None
return Notice(
reason=config.reason_text or "screen-time rules are active",
until=format_until(config),
names=report.blocked_names,
)
# -- reporting ---------------------------------------------------------
def publish_status(self, config: DeviceConfig | None = None, app: str | None = None) -> None:
link = self.state.link
if link is None:
return
current = config or self.state.config
link.publish_status(current.is_blocking(), app if app is not None else foreground_app())
def flush_telemetry(self) -> None:
link = self.state.link
if link is None:
return
day = day_key()
apps = self.state.telemetry.day(day)
if apps and link.publish_telemetry(day, apps):
self.log.info("telemetry republished day=%s apps=%d", day, len(apps))
def sample_usage(self) -> None:
"""One usage sample: visible-window apps get the elapsed seconds."""
apps = visible_apps()
totals = self.state.sampler.sample(apps)
if not totals:
return
day = day_key()
self.log.debug("usage sample day=%s apps=%d", day, len(totals))
link = self.state.link
if link is not None and link.publish_telemetry(day, totals):
self.log.info(
"telemetry day=%s apps=%d top=%s",
day,
len(totals),
json.dumps(build_payload(day, totals)["apps"][:3]),
)
# -- the loop ----------------------------------------------------------
def run(self) -> int:
self.load_config()
self.state.overlay.start()
link = MqttLink(
self.settings,
on_config=self.apply_config,
on_connected=self.on_connected,
on_lost=self.on_lost,
)
self.state.link = link
try:
link.start()
except Exception as exc: # noqa: BLE001 - run without a link, keep the rules
self.log.error("mqtt could not start (%s); enforcing the cached config only", exc)
self.state.started_at = time.monotonic()
next_telemetry = time.monotonic() + self.settings.telemetry_seconds
next_status = time.monotonic() + self.settings.status_seconds
self.log.info(
"client running: poll=%ss telemetry=%ss status=%ss device=%s state_dir=%s",
self.settings.poll_seconds,
self.settings.telemetry_seconds,
self.settings.status_seconds,
self.settings.device_token,
self.settings.state_path,
)
while not self.stop_event.is_set():
try:
report = self.tick()
if report.killed:
self.publish_status(self.state.config)
except Exception as exc: # noqa: BLE001 - one bad tick must not stop the client
self.log.exception("enforcement tick failed: %s", exc)
now = time.monotonic()
if now >= next_telemetry:
next_telemetry = now + self.settings.telemetry_seconds
try:
self.sample_usage()
except Exception as exc: # noqa: BLE001
self.log.warning("usage sample failed: %s", exc)
if now >= next_status:
next_status = now + self.settings.status_seconds
try:
self.publish_status(self.state.config)
self.log.info(
"status published locked=%s blocking=%s broker=%s cached_config=%s",
self.state.config.locked,
self.state.config.is_blocking(),
"connected" if (self.state.link and self.state.link.connected) else "unreachable",
bool(self.state.config.generated_at),
)
except Exception as exc: # noqa: BLE001
self.log.warning("status publish failed: %s", exc)
self.stop_event.wait(self.settings.poll_seconds)
self.shutdown()
return 0
def shutdown(self) -> None:
self.log.info("client stopping: publishing offline status")
link = self.state.link
if link is not None:
try:
link.publish_offline()
time.sleep(0.2) # let the retained offline message leave
link.stop()
except Exception as exc: # noqa: BLE001
self.log.debug("shutdown: %s", exc)
self.state.overlay.stop()
def request_stop(self, *_args) -> None:
self.stop_event.set()
def install_signal_handlers(agent: Agent) -> None:
"""Stop cleanly on Ctrl-C and on a Scheduled Task 'End task'."""
for name in ("SIGINT", "SIGTERM"):
handler = getattr(signal, name, None)
if handler is None:
continue
try:
signal.signal(handler, agent.request_stop)
except (ValueError, OSError): # pragma: no cover - not the main thread
pass
def check_once(settings: Settings) -> dict[str, object]:
"""A one-shot report for the operator and for the phase proof.
Prints the cached config, the effective block decision, and the processes it
would terminate right now. It changes nothing.
"""
cache = ConfigCache(settings.config_path())
config = cache.load() or DeviceConfig(platform="windows", locked=False)
enforcer = Enforcer(settings.protected)
from .blocker import select_victims # noqa: PLC0415 - only needed here
from .process_watch import snapshot_processes
now = datetime.now(timezone.utc)
victims = select_victims(
snapshot_processes(),
config,
now=now,
protected_names=enforcer.protected_names,
protected_pids=enforcer.protected_pids,
)
return {
"device_topic": settings.device_topic,
"state_dir": str(settings.state_path),
"checked_at": iso_now(),
"config": config.to_dict(),
"blocking": config.is_blocking(now),
"blocked_names": sorted(config.blocked_names()),
"victims": [{"pid": v.pid, "name": v.name, "rule": v.matched} for v in victims],
}

View File

@@ -0,0 +1,87 @@
"""Pure block-selection logic (AGENTS.md FR-3, FR-4, FR-16).
No psutil and no Windows here, so the decision can be unit tested anywhere.
:mod:`webrestrict.process_watch` supplies the running process list and does the
killing; this module only decides **which** processes are blocked.
The rules are deliberately small, because the client must never widen or narrow
them on its own (AGENTS.md §10 rule 7):
* nothing is blocked while the config is not effectively locked;
* a process is blocked when its normalised name is on the blocklist;
* the allowlist always wins (FR-4);
* the client never kills itself, its parent chain, or a protected system
process, whatever the blocklist says.
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from typing import Iterable
from .config import DeviceConfig, bare
@dataclass(frozen=True)
class Victim:
"""One process that must be terminated."""
pid: int
name: str # as reported by the OS, e.g. "notepad.exe"
matched: str # the normalised blocklist entry that matched
def select_victims(
processes: Iterable[tuple[int, str]],
config: DeviceConfig,
*,
now: datetime | None = None,
protected_names: Iterable[str] = (),
protected_pids: Iterable[int] = (),
) -> list[Victim]:
"""Choose the processes to terminate for one enforcement tick.
``processes`` is an iterable of ``(pid, name)`` pairs, so the caller can pass
a psutil snapshot, a fake list in a test, or a single process. The result is
ordered by pid and contains no duplicates, so the caller can log it.
"""
if not config.is_blocking(now):
return []
blocked = config.blocked_names()
if not blocked:
return []
allowed = config.allowed_names()
protected = {bare(item) for item in protected_names if item and str(item).strip()}
do_not_touch = {int(pid) for pid in protected_pids}
victims: list[Victim] = []
seen: set[int] = set()
for pid, name in processes:
try:
pid = int(pid)
except (TypeError, ValueError):
continue
value = bare(name)
if not value or pid in seen:
continue
seen.add(pid)
if pid in do_not_touch or value in protected or value in allowed:
continue
if value in blocked:
victims.append(Victim(pid=pid, name=name, matched=value))
victims.sort(key=lambda victim: (victim.matched, victim.pid))
return victims
def self_protection(pid: int, image_names: Iterable[str], parents: Iterable[int]) -> tuple[set[int], set[str]]:
"""The pids and names the enforcer must never touch for its own process tree.
The client runs as ``python.exe`` on the kid's machine, so a blocklist that
named Python would otherwise make the client kill itself and stop reporting.
"""
pids = {int(pid)} | {int(parent) for parent in parents}
names = {str(name) for name in image_names if str(name).strip()}
return pids, names

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

View File

@@ -0,0 +1,241 @@
"""The MQTT link to the 8883 device plane (AGENTS.md §10 rule 6, FR-6).
The client is one device account on the TLS listener. Its ACL lets it read only
``restrict/<token>/config`` and write only its own status and telemetry, so the
broker itself stops a child publishing a fake "unlocked" (TRANSPORT_DESIGN.md
"Security").
Presence is the broker's Last Will and Testament: the client publishes a
retained ``{"online": true}`` when it connects and registers the will
``{"online": false}`` on the same topic, so the Control Service learns the
device is gone the moment the connection drops without a clean goodbye (FR-6).
A broker that is down is not fatal: the cached config stays in force (FR-14),
paho retries on a backoff, and the retained config is delivered again on
reconnect.
"""
from __future__ import annotations
import json
import logging
import ssl
from datetime import datetime, timezone
from typing import Callable, Mapping
from .settings import Settings
log = logging.getLogger("webrestrict.mqtt")
QOS = 1
KEEPALIVE_SECONDS = 30
def iso_now(moment: datetime | None = None) -> str:
"""RFC 3339 in UTC, as the Control Service writes timestamps."""
current = moment or datetime.now(timezone.utc)
if current.tzinfo is None:
current = current.replace(tzinfo=timezone.utc)
return current.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def status_payload(locked: bool, app: str = "", tamper: dict[str, str] | None = None) -> str:
"""The status report the dashboard reads (FR-6, FR-9, FR-17)."""
body: dict[str, object] = {"online": True, "locked": locked, "app": app, "at": iso_now()}
if tamper:
body["tamper"] = {**tamper, "at": iso_now()}
return json.dumps(body, sort_keys=True)
def telemetry_payload(day: str, apps: Mapping[str, int]) -> str:
"""A day's per-app totals. Not retained: it is a report, not a rule."""
return json.dumps(
{
"day": day,
"apps": [
{"name": name, "seconds": int(seconds)}
for name, seconds in sorted(apps.items())
if name and int(seconds) > 0
],
"domains": [],
},
sort_keys=True,
)
class MqttLink:
"""A reconnecting paho client wrapper with a small, testable surface."""
def __init__(
self,
settings: Settings,
on_config: Callable[[str], None],
on_connected: Callable[[], None] | None = None,
on_lost: Callable[[str], None] | None = None,
):
self.settings = settings
self.on_config = on_config
self.on_connected = on_connected or (lambda: None)
self.on_lost = on_lost or (lambda reason: None)
self.connected = False
self.last_error = ""
self._client = None
self._stopped = False
self._unreachable_logged = False
# -- lifecycle ---------------------------------------------------------
def start(self) -> None:
"""Create the client and begin connecting in the background."""
import paho.mqtt.client as mqtt # noqa: PLC0415 - keep the import local
client = self._new_client(mqtt)
self._client = client
client.on_connect = self._handle_connect
client.on_connect_fail = self._handle_connect_fail
client.on_disconnect = self._handle_disconnect
client.on_message = self._handle_message
client.username_pw_set(self.settings.mqtt_username, self.settings.mqtt_password or None)
if self.settings.ca_file:
client.tls_set(
ca_certs=self.settings.ca_file,
cert_reqs=ssl.CERT_REQUIRED,
tls_version=ssl.PROTOCOL_TLS_CLIENT,
)
else: # pragma: no cover - the lab always pins the CA
client.tls_set(cert_reqs=ssl.CERT_REQUIRED, tls_version=ssl.PROTOCOL_TLS_CLIENT)
# Presence (FR-6): the broker publishes this the moment the link drops
# without a clean DISCONNECT.
client.will_set(
self.settings.status_topic,
json.dumps({"online": False}),
qos=QOS,
retain=True,
)
client.reconnect_delay_set(min_delay=1, max_delay=30)
log.info(
"mqtt connecting to %s:%s as %s (topic %s)",
self.settings.broker_host,
self.settings.broker_port,
self.settings.mqtt_username,
self.settings.device_topic,
)
client.connect_async(
self.settings.broker_host,
self.settings.broker_port,
keepalive=KEEPALIVE_SECONDS,
)
client.loop_start()
def stop(self) -> None:
"""Disconnect cleanly, which suppresses the will."""
self._stopped = True
client = self._client
if client is None:
return
try:
client.disconnect()
client.loop_stop()
except Exception as exc: # noqa: BLE001 - shutting down anyway
log.debug("mqtt stop: %s", exc)
# -- publishing --------------------------------------------------------
def publish_status(self, locked: bool, app: str = "", tamper: dict[str, str] | None = None) -> bool:
return self._publish(self.settings.status_topic, status_payload(locked, app, tamper), retain=True)
def publish_telemetry(self, day: str, apps: Mapping[str, int]) -> bool:
if not apps:
return False
return self._publish(self.settings.telemetry_topic, telemetry_payload(day, apps), retain=False)
def publish_offline(self) -> bool:
"""Retained offline status, published on a clean shutdown (FR-6)."""
return self._publish(self.settings.status_topic, json.dumps({"online": False}), retain=True)
def _publish(self, topic: str, payload: str, *, retain: bool) -> bool:
client = self._client
if client is None or not self.connected:
return False
try:
info = client.publish(topic, payload, qos=QOS, retain=retain)
if info.rc != 0:
log.warning("publish to %s rejected (rc=%s)", topic, info.rc)
return False
return True
except Exception as exc: # noqa: BLE001 - a lost link retries
log.warning("publish to %s failed: %s", topic, exc)
return False
# -- paho plumbing -----------------------------------------------------
def _new_client(self, mqtt):
"""paho 2.x takes an explicit callback API version; 1.x does not."""
try:
return mqtt.Client(
callback_api_version=mqtt.CallbackAPIVersion.VERSION2,
client_id=self.settings.effective_client_id,
clean_session=True,
protocol=mqtt.MQTTv311,
)
except TypeError: # pragma: no cover - paho 1.x
return mqtt.Client(
client_id=self.settings.effective_client_id,
clean_session=True,
protocol=mqtt.MQTTv311,
)
def _handle_connect(self, client, _userdata, _flags, reason_code, _properties=None):
code = getattr(reason_code, "value", reason_code)
try:
code = int(code)
except (TypeError, ValueError):
code = 1
if code != 0:
self.connected = False
self.last_error = f"connect refused: {reason_code}"
log.warning("mqtt connect refused: %s", reason_code)
return
self.connected = True
self.last_error = ""
self._unreachable_logged = False
log.info("mqtt connected, subscribing to %s", self.settings.device_topic)
client.subscribe(self.settings.device_topic, qos=QOS)
# on_connected publishes the retained online status with the real
# locked state, and flushes any telemetry held while offline.
self.on_connected()
def _handle_connect_fail(self, *_args):
"""The broker could not be reached at all (FR-14).
Logged once per outage so the record shows the client is running on its
cached config rather than sitting idle, and not flooded by paho's retry.
"""
self.connected = False
self.last_error = "broker unreachable"
if not self._unreachable_logged:
self._unreachable_logged = True
log.warning(
"broker %s:%s is unreachable; enforcing the cached config only (FR-14)",
self.settings.broker_host,
self.settings.broker_port,
)
self.on_lost("broker unreachable")
def _handle_disconnect(self, _client, _userdata, _flags, reason_code, _properties=None):
self.connected = False
message = str(reason_code)
self.last_error = message
if self._stopped:
log.info("mqtt disconnected (client stopped): %s", message)
return
log.warning("mqtt connection lost: %s — keeping the last config (FR-14)", message)
self.on_lost(message)
def _handle_message(self, _client, _userdata, message):
if message.topic != self.settings.device_topic:
return
payload = message.payload.decode("utf-8", "replace")
try:
self.on_config(payload)
except Exception as exc: # noqa: BLE001 - a bad config must not kill the link
log.warning("config rejected, keeping the last one: %s", exc)

View File

@@ -0,0 +1,147 @@
"""The child's warning window (AGENTS.md FR-9).
A small always-on-top Tkinter window: what is blocked, why, and until when. It
is an *alert*, not a defence in depth — the child can close it, and on Windows
the enforcement is the process block itself (FR-3) plus the presence and
offline reports that reach Sam (FR-6, FR-13).
Tkinter wants one thread that owns the interpreter, so the window runs on its
own daemon thread and the agent talks to it through a queue. If Tk is missing,
or there is no interactive desktop (a service session, a headless test), the
overlay is simply unavailable and blocking continues.
"""
from __future__ import annotations
import logging
import queue
import threading
from dataclasses import dataclass
from typing import Sequence
log = logging.getLogger("webrestrict.overlay")
TITLE = "Screen time"
@dataclass(frozen=True)
class Notice:
"""What the window shows while a block is active."""
reason: str
until: str
names: Sequence[str]
@property
def blocked_text(self) -> str:
if not self.names:
return "This computer is blocked while the window is active."
shown = ", ".join(self.names[:8])
if len(self.names) > 8:
shown += f", and {len(self.names) - 8} more"
return f"Blocked now: {shown}"
class Overlay:
"""A queue-fed Tkinter window with a tiny, testable interface."""
def __init__(self, enabled: bool = True):
self.enabled = enabled
self.available = False
self._queue: "queue.Queue[tuple[str, object]]" = queue.Queue()
self._thread: threading.Thread | None = None
self._shown: Notice | None = None
def start(self) -> None:
if not self.enabled:
log.info("overlay disabled by configuration")
return
self._thread = threading.Thread(target=self._run, name="webrestrict-overlay", daemon=True)
self._thread.start()
def update(self, notice: Notice | None) -> None:
"""Show the notice, hide on ``None``. Repeat calls with the same content are dropped."""
if notice == self._shown:
return
self._shown = notice
if notice is None:
self._queue.put(("hide", None))
else:
self._queue.put(("show", notice))
def stop(self) -> None:
self._queue.put(("stop", None))
# ---------------------------------------------------------------------
def _run(self) -> None: # pragma: no cover - needs a desktop
try:
import tkinter as tk
except ImportError as exc:
log.warning("tkinter is unavailable, no warning window: %s", exc)
return
try:
root = tk.Tk()
except Exception as exc: # noqa: BLE001 - no interactive desktop
log.warning("the warning window is unavailable: %s", exc)
return
root.withdraw()
root.title(TITLE)
root.attributes("-topmost", True)
root.resizable(False, False)
frame = tk.Frame(root, padx=18, pady=16)
frame.pack(fill="both", expand=True)
heading = tk.Label(frame, text="Screen time is over", font=("Segoe UI", 14, "bold"), anchor="w")
heading.pack(fill="x")
reason_label = tk.Label(frame, text="", wraplength=380, justify="left", anchor="w")
reason_label.pack(fill="x", pady=(8, 0))
until_label = tk.Label(frame, text="", anchor="w", font=("Segoe UI", 9))
until_label.pack(fill="x", pady=(6, 0))
blocked_label = tk.Label(frame, text="", wraplength=380, justify="left", anchor="w")
blocked_label.pack(fill="x", pady=(8, 0))
tk.Button(frame, text="Close warning", command=root.withdraw).pack(anchor="e", pady=(12, 0))
root.geometry("420x260")
self.available = True
log.info("warning window is available")
def drain() -> None: # pragma: no cover - needs a desktop
stop = False
while True:
try:
action, payload = self._queue.get_nowait()
except queue.Empty:
break
if action == "show" and isinstance(payload, Notice):
reason_label.config(text=f"The rules say: {payload.reason}.")
until_label.config(text=f"Until {payload.until}." if payload.until else "No end time is set.")
blocked_label.config(text=payload.blocked_text)
root.deiconify()
root.lift()
root.attributes("-topmost", True)
place_bottom_right(root)
elif action == "hide":
root.withdraw()
elif action == "stop":
stop = True
if stop:
root.destroy()
return
root.after(200, drain)
root.after(200, drain)
try:
root.mainloop()
except Exception as exc: # noqa: BLE001 - nothing left to do
log.debug("warning window loop ended: %s", exc)
def place_bottom_right(root) -> None: # pragma: no cover - needs a desktop
"""Park the window bottom-right, out of the way of what it is covering."""
root.update_idletasks()
width = root.winfo_width() or 420
height = root.winfo_height() or 260
screen = root.winfo_screenwidth(), root.winfo_screenheight()
root.geometry(f"{width}x{height}+{max(screen[0] - width - 24, 0)}+{max(screen[1] - height - 72, 0)}")

View File

@@ -0,0 +1,263 @@
"""The Windows side of enforcement: process list, window list, foregound app.
Everything here touches the real machine, so it is kept apart from the pure
decision in :mod:`webrestrict.blocker`. ``psutil`` is the only third-party
dependency; the window queries go through ``ctypes`` and ``user32`` so the
client needs no ``pywin32`` install.
Off Windows (unit tests, a lab box) every function degrades to an empty result
instead of raising, so the client stays importable and testable.
"""
from __future__ import annotations
import ctypes
import logging
import os
import sys
from dataclasses import dataclass
from datetime import datetime
from typing import Iterable
from .blocker import Victim, select_victims, self_protection
from .config import DeviceConfig, bare
log = logging.getLogger("webrestrict.process")
IS_WINDOWS = os.name == "nt"
# How long to give a polite terminate() before the hard kill(), in seconds.
TERMINATE_GRACE_SECONDS = 3.0
def _psutil():
"""Import psutil on demand, with a readable error when it is missing."""
try:
import psutil # noqa: PLC0415 (deliberate: keeps the import cost local)
except ImportError as exc: # pragma: no cover - depends on the machine
raise RuntimeError(
"psutil is not installed; run: python -m pip install -r windows/requirements.txt"
) from exc
return psutil
def snapshot_processes() -> list[tuple[int, str]]:
"""Every running process as ``(pid, name)``.
A process that exits while the list is built, or that denies access, is
skipped: it is not a reason to fail an enforcement tick.
"""
psutil = _psutil()
out: list[tuple[int, str]] = []
for proc in psutil.process_iter(["pid", "name"]):
try:
info = proc.info
name = info.get("name") or ""
pid = int(info.get("pid") or 0)
except Exception: # noqa: BLE001 - a dead process is normal
continue
if pid and name:
out.append((pid, name))
return out
def kill_process(pid: int, name: str) -> str:
"""Terminate one process. Returns ``"terminated"`` or ``"killed"``.
``terminate()`` asks the app to close, which is exactly what a scheduled
window does to a game. If it is still alive after a short grace, ``kill()``
ends it. Access denied is reported, not raised: the machine keeps running.
"""
psutil = _psutil()
try:
proc = psutil.Process(pid)
except Exception as exc: # noqa: BLE001 - the process is already gone
log.debug("process %s (%s) vanished: %s", pid, name, exc)
return "gone"
try:
proc.terminate()
except Exception as exc: # noqa: BLE001 - e.g. AccessDenied for another user
log.warning("terminate %s (%s) failed: %s", pid, name, exc)
return "failed"
try:
proc.wait(timeout=TERMINATE_GRACE_SECONDS)
return "terminated"
except Exception: # noqa: BLE001 - still running, escalate
pass
try:
proc.kill()
proc.wait(timeout=TERMINATE_GRACE_SECONDS)
return "killed"
except Exception as exc: # noqa: BLE001
log.warning("kill %s (%s) failed: %s", pid, name, exc)
return "failed"
# --- Windows window probes (ctypes; no pywin32) --------------------------
_WNDENUMPROC = ctypes.WINFUNCTYPE(ctypes.c_bool, ctypes.c_void_p, ctypes.c_void_p) if IS_WINDOWS else None
def _user32():
return ctypes.windll.user32 # type: ignore[attr-defined]
def window_pids() -> set[int]:
"""Pids that own a visible, titled top-level window.
That is the closest cheap proxy Windows offers for "the child can see this
app". A background app with a hidden window does not count.
"""
if not IS_WINDOWS: # pragma: no cover - platform dependent
return set()
user32 = _user32()
pids: set[int] = set()
def callback(hwnd, _lparam): # pragma: no cover - platform dependent
try:
if not user32.IsWindowVisible(hwnd):
return True
if user32.GetWindowTextLengthW(hwnd) == 0:
return True
pid = ctypes.c_ulong(0)
user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid))
if pid.value:
pids.add(int(pid.value))
except Exception: # noqa: BLE001 - never let a callback break the enum
return True
return True
try:
user32.EnumWindows(_WNDENUMPROC(callback), 0)
except Exception as exc: # noqa: BLE001 - pragma: no cover
log.debug("EnumWindows failed: %s", exc)
return pids
def foreground_pid() -> int | None:
"""The pid of the window the child is using, or ``None`` off Windows."""
if not IS_WINDOWS: # pragma: no cover - platform dependent
return None
try:
hwnd = _user32().GetForegroundWindow()
if not hwnd:
return None
pid = ctypes.c_ulong(0)
_user32().GetWindowThreadProcessId(hwnd, ctypes.byref(pid))
return int(pid.value) or None
except Exception: # noqa: BLE001 - pragma: no cover
return None
def process_name(pid: int) -> str:
"""The image name for one pid, or an empty string."""
psutil = _psutil()
try:
return psutil.Process(pid).name() or ""
except Exception: # noqa: BLE001 - the process ended
return ""
def foreground_app() -> str:
"""The foreground app's image name, for the status report (FR-9)."""
pid = foreground_pid()
return process_name(pid) if pid else ""
def visible_apps(exclude_pids: Iterable[int] | None = None) -> list[str]:
"""Names of processes with a visible, titled window right now.
The client's own process is excluded by default: its warning window has a
title and would otherwise be counted as the child's usage.
"""
pids = window_pids()
if not pids:
return []
skip = {int(pid) for pid in (exclude_pids if exclude_pids is not None else (os.getpid(),))}
pids -= skip
return sorted({name for name in (process_name(pid) for pid in pids) if name})
@dataclass
class EnforcementReport:
"""What one tick did, for the log and the status report."""
blocking: bool
blocked_names: tuple[str, ...]
killed: tuple[Victim, ...]
failed: tuple[Victim, ...]
@property
def killed_names(self) -> list[str]:
return sorted({victim.matched for victim in self.killed})
class Enforcer:
"""Terminates blocked processes while the device is effectively locked."""
def __init__(self, protected: Iterable[str], log_extra: logging.Logger | None = None):
self.log = log_extra or log
self.protected_names = {bare(name) for name in protected if name and name.strip()}
self.protected_pids: set[int] = set()
# The client's own process tree is never a victim, even if a blocklist
# names python.exe: killing itself would end enforcement silently.
parents = _parent_pids(os.getpid())
pids, names = self_protection(os.getpid(), {os.path.basename(sys.executable), "python", "pythonw"}, parents)
self.protected_pids |= pids
self.protected_names |= {bare(name) for name in names}
def enforce(self, config: DeviceConfig, now: datetime | None = None) -> EnforcementReport:
"""One enforcement tick: find the victims, kill them, report the result."""
blocking = config.is_blocking(now)
victims = select_victims(
snapshot_processes(),
config,
now=now,
protected_names=self.protected_names,
protected_pids=self.protected_pids,
)
killed: list[Victim] = []
failed: list[Victim] = []
for victim in victims:
outcome = kill_process(victim.pid, victim.name)
if outcome in ("terminated", "killed"):
self.log.info(
"blocked %s (pid=%s, rule=%s): %s", victim.name, victim.pid, victim.matched, outcome
)
killed.append(victim)
elif outcome == "failed":
failed.append(victim)
if killed:
self.log.info("enforcement killed %d process(es): %s", len(killed), ", ".join(
f"{v.name}({v.pid})" for v in killed
))
return EnforcementReport(
blocking=blocking,
blocked_names=tuple(sorted(config.blocked_names())),
killed=tuple(killed),
failed=tuple(failed),
)
def _parent_pids(pid: int) -> set[int]:
"""The pid's ancestor chain, skipping failures."""
psutil = _psutil()
out: set[int] = set()
try:
proc = psutil.Process(pid)
except Exception: # noqa: BLE001 - pragma: no cover
return out
for _ in range(10):
try:
parent = proc.parent()
except Exception: # noqa: BLE001
break
if parent is None or parent.pid in out:
break
out.add(parent.pid)
proc = parent
return out

View File

@@ -0,0 +1,178 @@
"""Client settings, read from one JSON file (AGENTS.md §11 Phase 4).
Everything the client needs is local configuration: the broker address, the
device's own topic and MQTT account, and where to keep its state. Nothing here
is a second source of truth — the Control Service still owns the rules.
The file is provisioned on the machine and is **never** committed. It holds the
device's MQTT password, which must stay off the repository (AGENTS.md §10 rule
8). The path comes from ``--config``, then ``WEBRESTRICT_CONFIG``, then
``%PROGRAMDATA%\\WebRestrict\\client.json``.
"""
from __future__ import annotations
import json
import os
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
# Default location. ProgramData is machine-wide and readable by the New Comp
# account, which is what the logon Scheduled Task runs as.
DEFAULT_CONFIG_NAME = "client.json"
DEFAULT_STATE_DIR_NAME = "WebRestrict"
# Never kill these, whatever a blocklist says. The client must not be able to
# take the machine down, and it must not kill itself (it runs as python.exe).
# The client's own interpreter and its parent chain are added at runtime.
DEFAULT_PROTECTED: tuple[str, ...] = (
"system",
"system idle process",
"registry",
"smss",
"csrss",
"wininit",
"winlogon",
"services",
"lsass",
"svchost",
"dwm",
"explorer",
"taskhostw",
"sihost",
"ctfmon",
"conhost",
"fontdrvhost",
"audiodg",
"msmpeng",
"securityhealthservice",
"searchindexer",
"startmenuexperiencehost",
"shellexperiencehost",
"textinputhost",
"applicationframehost",
"python",
"pythonw",
)
def default_program_data() -> Path:
"""The machine-wide directory for client configuration and state."""
base = os.environ.get("PROGRAMDATA") or os.environ.get("ALLUSERSPROFILE")
if base:
return Path(base) / DEFAULT_STATE_DIR_NAME
# Not Windows (unit tests, a lab box): keep it under the user's home.
return Path.home() / ".webrestrict"
def default_config_path() -> Path:
"""The config path: ``WEBRESTRICT_CONFIG`` wins, else ``%PROGRAMDATA%``."""
override = os.environ.get("WEBRESTRICT_CONFIG")
if override:
return Path(override)
return default_program_data() / DEFAULT_CONFIG_NAME
@dataclass
class Settings:
"""Everything the client needs to run, with safe lab defaults.
A missing field falls back to the default, so a hand-written config file
only needs the values that differ. Unknown fields are ignored, so the file
may grow without a client update.
"""
# --- the link ---------------------------------------------------------
broker_host: str = "192.168.20.13"
broker_port: int = 8883
ca_file: str = "" # empty = the platform trust store
device_topic: str = ""
mqtt_username: str = ""
mqtt_password: str = ""
client_id: str = "" # empty = derive from the topic token
# --- local state ------------------------------------------------------
state_dir: str = ""
# --- timings (seconds) ------------------------------------------------
poll_seconds: float = 5.0
telemetry_seconds: float = 60.0
status_seconds: float = 60.0
connect_timeout_seconds: float = 10.0
# --- behaviour --------------------------------------------------------
overlay: bool = True
protected: list[str] = field(default_factory=lambda: list(DEFAULT_PROTECTED))
# ---------------------------------------------------------------------
@property
def state_path(self) -> Path:
"""The directory holding the cached config, the usage roll-up and the log."""
if self.state_dir:
return Path(self.state_dir)
if self.device_token:
return default_program_data() / self.device_token
return default_program_data()
@property
def device_token(self) -> str:
"""The middle topic level, which is also the broker account name."""
base = self.device_topic.removesuffix("/config")
return base.rsplit("/", 1)[-1] if "/" in base else base
@property
def status_topic(self) -> str:
return self.device_topic.removesuffix("/config") + "/status"
@property
def telemetry_topic(self) -> str:
return self.device_topic.removesuffix("/config") + "/telemetry"
@property
def effective_client_id(self) -> str:
return self.client_id or f"kid-{self.device_token or 'windows'}"
def config_path(self) -> Path:
return self.state_path / "config-cache.json"
def telemetry_path(self) -> Path:
return self.state_path / "telemetry.json"
def log_path(self) -> Path:
return self.state_path / "client.log"
def validate(self) -> None:
"""Raise when the client cannot possibly work, instead of failing later."""
if not self.device_topic:
raise ValueError("device_topic is not set (e.g. restrict/finn_s_computer_2/config)")
if not self.device_topic.endswith("/config"):
raise ValueError(f"device_topic must end with /config: {self.device_topic!r}")
if not self.broker_host:
raise ValueError("broker_host is not set")
if not 1 <= self.broker_port <= 65535:
raise ValueError(f"broker_port is out of range: {self.broker_port}")
if not self.mqtt_username:
raise ValueError("mqtt_username is not set (it is the topic token)")
# ---------------------------------------------------------------------
@classmethod
def from_dict(cls, raw: dict[str, Any]) -> "Settings":
"""Build settings from a parsed JSON object, ignoring unknown keys."""
known = {f for f in cls.__dataclass_fields__} # type: ignore[attr-defined]
data = {k: v for k, v in raw.items() if k in known}
settings = cls(**data)
settings.broker_port = int(settings.broker_port)
return settings
@classmethod
def load(cls, path: str | os.PathLike[str] | None = None) -> "Settings":
"""Read the config file and validate it."""
target = Path(path) if path else default_config_path()
with open(target, "r", encoding="utf-8") as handle:
raw = json.load(handle)
if not isinstance(raw, dict):
raise ValueError(f"{target}: expected a JSON object")
settings = cls.from_dict(raw)
settings.validate()
return settings

View File

@@ -0,0 +1,155 @@
"""Daily usage roll-up and its local store (AGENTS.md FR-7).
The client publishes ``{"day":...,"apps":[{"name":...,"seconds":N}]}`` where the
seconds are **totals for the day**, not deltas: the Control Service replaces the
row for (device, day, app) on every report (``internal/store/migrations/002``).
Windows has no per-app foreground clock like Android's Usage Access, so the
client builds its own total: every sample, the elapsed seconds are added to each
app that currently owns a visible, titled top-level window. That total is kept
on disk so a restart never loses the day, and it is republished when the link
returns (FR-14).
**Domains are not attributed.** Windows gives no cheap per-process DNS answer —
it needs a packet capture, an ETW consumer or the DNS Client event log, all of
which are either a second dependency or an administrator-only channel. The
client therefore publishes ``"domains": []`` and the gap is recorded in
``windows/README.md`` rather than filled with a guess.
"""
from __future__ import annotations
import json
import logging
import os
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Iterable, Mapping
log = logging.getLogger("webrestrict.telemetry")
# Keep two weeks of daily totals on the device, matching the Android client.
MAX_DAYS = 14
# A sample longer than this is treated as this long, so a laptop that was asleep
# does not credit one app with hours of "use".
MAX_SAMPLE_SECONDS = 120.0
def day_key(moment: datetime | None = None) -> str:
"""The local ``YYYY-MM-DD`` the usage belongs to.
An aware timestamp is converted to local time first, so a day boundary is the
child's midnight rather than UTC's.
"""
current = moment or datetime.now()
if current.tzinfo is not None:
current = current.astimezone()
return current.strftime("%Y-%m-%d")
def to_entries(apps: Mapping[str, int]) -> list[dict[str, object]]:
"""The list form the telemetry topic accepts, sorted and positive-only."""
return [
{"name": name, "seconds": int(seconds)}
for name, seconds in sorted(apps.items())
if name and int(seconds) > 0
]
def build_payload(day: str, apps: Mapping[str, int]) -> dict[str, object]:
"""The Phase 3 telemetry payload (``internal/mqtt/payload.go``)."""
return {"day": day, "apps": to_entries(apps), "domains": []}
@dataclass
class TelemetryStore:
"""day -> app -> seconds, kept on disk so a restart or outage loses nothing."""
path: Path
days: dict[str, dict[str, int]] = field(default_factory=dict)
loaded: bool = False
def load(self) -> "TelemetryStore":
if self.loaded:
return self
self.loaded = True
try:
if not self.path.exists():
return self
with open(self.path, "r", encoding="utf-8") as handle:
raw = json.load(handle)
days = raw.get("days") if isinstance(raw, dict) else None
if isinstance(days, dict):
self.days = {
str(day): {str(name): int(seconds) for name, seconds in row.items()}
for day, row in days.items()
if isinstance(row, dict)
}
except (OSError, ValueError, TypeError, json.JSONDecodeError) as exc:
log.warning("telemetry store is unreadable, starting empty: %s", exc)
self.days = {}
return self
def save(self) -> None:
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:
json.dump({"days": self.days}, handle, sort_keys=True)
os.replace(temp, self.path)
def day(self, day: str) -> dict[str, int]:
self.load()
return dict(self.days.get(day, {}))
def add_seconds(self, day: str, apps: Mapping[str, int]) -> dict[str, int]:
"""Add a sample to a day and return that day's new totals."""
self.load()
row = self.days.setdefault(day, {})
for name, seconds in apps.items():
if not name or seconds <= 0:
continue
row[name] = row.get(name, 0) + int(seconds)
self.prune()
self.save()
return dict(row)
def prune(self) -> None:
"""Keep only the [MAX_DAYS] most recent days."""
if len(self.days) <= MAX_DAYS:
return
for old in sorted(self.days)[:-MAX_DAYS]:
self.days.pop(old, None)
class UsageSampler:
"""Turns "these apps had a window" ticks into per-app daily seconds."""
def __init__(self, store: TelemetryStore):
self.store = store
self.last_at: datetime | None = None
def sample(
self,
apps: Iterable[str],
now: datetime | None = None,
day: str | None = None,
) -> dict[str, int]:
"""Credit the elapsed time to each app in the sample.
Returns the day's totals. The first call only starts the clock: there is
no way to know what ran before the client started.
"""
moment = now or datetime.now(timezone.utc)
self.last_at = self.last_at or moment
elapsed = (moment - self.last_at).total_seconds()
self.last_at = moment
if elapsed <= 0:
return self.store.day(day or day_key(moment))
elapsed = min(elapsed, MAX_SAMPLE_SECONDS)
wanted = sorted({name for name in apps if name})
if not wanted:
return self.store.day(day or day_key(moment))
return self.store.add_seconds(day or day_key(moment), {name: int(elapsed) for name in wanted})