Files
internet_use_control/windows/README.md
Sam Rolfe 1275b1baaa 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.
2026-10-14 15:00:22 +11:00

10 KiB

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:

{"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):

{"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:

    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):

    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:

    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:

    {
      "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 -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:

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).