0026. Parental controls: web-activity observability and on-demand web filtering¶
- Status: accepted
- Date: 2026-07-02
Context and Problem Statement¶
kenny is operated in a family setting. A recurring need is Jugendschutz (child protection): a parent wants to see which sites a child's PC has been reaching and, on demand, block a set of known-harmful domains — with a list that ships pre-populated, stays editable, and can reference a maintained external source. Nothing in kenny touches web activity, browsing, DNS content, or content filtering today, so this is greenfield and crosses the wire contract: it needs a new telemetry section (observability), new capability tools (blocking), a per-host editable list with a server-side store, and a dashboard surface.
Three properties shape the design:
- Observability must not depend on blocking. A child with local admin rights can edit the hosts file or a registry policy; a VPN or portable browser defeats host-level blocking entirely. So the alarm (a parent finding out a listed site was reached) has to work regardless of whether blocking is applied or was circumvented. That makes the telemetry + server-side matching the primary control and blocking a best-effort add-on.
- DNS-over-HTTPS is the blind spot. Modern Chrome/Edge/Firefox resolve names over DoH, bypassing both the OS DNS client cache (our cheapest observation source) and hosts-file blocking. Any serious version of this feature must both observe through a second source (browser history) and, when blocking, disable DoH via browser policy.
- The existing safety architecture must absorb this, not be worked around. The local kill switch (ADR-0011), the confirm-gate (ADR-0009), and the deterministic guard (ADR-0020) all already gate mutating tools by name. Blocking should ride those, not add a new bypass.
Considered Options¶
- Observation source: DNS client cache only / browser history only / both (chosen).
The DNS cache (
Get-DnsClientCache) is cheap and catches every app, but is blind to DoH and short-lived. Browser history (Historyfor Chromium,places.sqlitefor Firefox) catches DoH-resolved visits and carries timestamps for a real 24 h window, but only sees browsers and requires reading per-user profile DBs. Together they cover each other's gaps; chosen. Deferred: the DNS-Client operational event log, which must be explicitly enabled and is high-volume — a later addition if the two chosen sources prove insufficient. - List push mechanism: new
policy-style frame / capability tool call (chosen). A frame is fire-and-forget with no ack and would either bypass the kill switch or need new gating. Arequest/responsetool call gives an ack + applied-state (for drift display), reuses the audit log and confirm-gate, and — being mutating — is refused withdisabledunder the kill switch for free. Chosen. - 24 h rolling window: agent-side accumulation / server-side accumulation (chosen). The
collector registry relies on collectors being stateless
fn()s isolated bycatch_unwind; a ring buffer in the agent would break that invariant and be lost on restart. The server already owns a retained store. The agent reports what is observable now; the server dedups and accumulates. Chosen. - Blocking: hosts file / DNS sinkhole / proxy / hosts + DoH-off + bypass category (chosen). A local DNS sinkhole or proxy is a much larger, always-on component to secure and deploy. A hosts-file block plus registry policies that disable browser DoH is simple, reversible, and needs no new transport; adding a bypass-protection category (VPN/proxy/DoH-provider domains) raises the effort to circumvent. Chosen, with eyes open about its limits (below).
- External source: none (seed only) / StevenBlack porn-only hosts (chosen) + hagezi bypass.
A hand-maintained seed alone goes stale. StevenBlack's
porn-onlyhosts list is MIT, hosts-format, curated from several sources, and stably hosted; used server-side for alarm matching and, capped, for agent blocking. The hagezidoh-vpn-proxy-bypasslist backs the bypass category. Both are fetched and cached server-side with a seed fallback.
Decision Outcome¶
Chosen: a web_activity telemetry section for observability plus webfilter_* capability
tools for on-demand blocking, driven by a per-host editable list held server-side, with the
server as the authoritative matcher and the agent a dumb, idempotent enforcer.
PROTOCOL_VERSION bumps 0.8 → 0.9 (additive: one section, three tools, no frame changes).
- Observation —
web_activitysection. The agent collector reads the DNS client cache and each user's browser history (ChromiumHistory, Firefoxplaces.sqlite; copied to a temp file first to dodge SQLite locks, opened read-only), extracts host names only, merges/dedups into a bounded list (cap 250,truncatedflag) covering the last 24 h, and reportsstatus: "ok"— it has no list and does not judge. Off Windows it returns the standardn/a on this platformstub. Browser credential stores stay protected by the existingfs_readdeny rules; history DBs are distinct files read only inside the collector. - Alarm — server-side matching, thresholds in
health_rules.py. On telemetry insert the server matches observed domains (suffix match, sosub.bad.examplehitsbad.example) against that host's effective list and annotates the stored payload with aflaggedarray (domain, category, matched entry, timestamps). Theweb_activityhealth rule readsflaggedand decideswarn/crit; thresholds stay only inhealth_rules.py, per the telemetry contract. Theflaggedannotation is server-internal and never on the wire. - List model — layered, per host. A built-in seed of well-known adult domains ships
with the server (read-only, upgradable), external sources (StevenBlack adult, hagezi bypass)
are referenced read-only and toggled per host, and per-host custom entries
(
watch|block|allow) layer on top; anallowentry overrides a block on an equal-or-more-specific suffix. This mirrors the ADR-0021 split of a shared catalog vs. operator additions. Each host independently turns the feature on/off and toggles blocking. - Blocking —
webfilter_*tools. The server pre-merges the effective block set (custom blocks + seed + capped external adult extract + bypass domains − allows) into a flat domain list and calls: webfilter_status({}) — read-only, works under the kill switch: reports the applied block count, alist_hashfor drift detection, and current browser DoH policy.webfilter_apply({domains, doh_policy, list_hash}) — mutating: writes a marker-delimited block to the hosts file (0.0.0.0, hard cap 10 000, atomic replace), optionally sets registry policies disabling DoH in Chrome/Edge/Firefox, and flushes DNS.webfilter_clear({}) — mutating: removes only kenny's marker block and the kenny-written policy values. The agent stays dumb and idempotent (no bypass logic on the wire) and refuses any list that would blackhole self-protected names (localhost, the configured server host, core Microsoft-update infrastructure) withblocked, so a bad list can never sever the tunnel or OS updates.- Kill-switch interplay (honest limitation).
webfilter_apply/clearare classified mutating incontrol::is_mutating, so when the person at the PC switches remote control off they are refused withdisabled— the child can locally refuse new rules. Rules already written to the hosts file persist until cleared, andweb_activitytelemetry keeps flowing regardless. This is by design: monitoring is the guarantee, blocking is best-effort. - External sources. The server fetches StevenBlack
porn-onlyhosts and the hagezidoh-vpn-proxy-bypasslist on a timer (default 24 h), with size guards, a write-through disk cache, and a seed-only fallback when offline. URLs are environment-overridable. - Portability. The collector's parsing/merging core and the handler's hosts-splicing and
validation core are
#[cfg(windows)]-free and unit-tested on Linux CI; only the OS probes (DNS cache, registry, real hosts path) are Windows-gated. Off Windows the tools returnunsupported.
Consequences¶
- Good: a parent gets a real alarm the next snapshot after a listed site is reached, whether or not blocking is on or was bypassed — the primary goal. Blocking is a reversible, transport-free add-on that also closes the DoH hole for the common (non-adversarial) case.
- Good: the feature reuses the telemetry pipeline, the health-rule pattern, the ADR-0021 list model, the kill switch, and the confirm-gate rather than inventing parallel machinery. The first editable-list UI in the dashboard establishes a reusable custom-section renderer.
- Bad / trade-offs: host-level blocking is defeated by admin rights, a VPN, or a portable browser; we state this plainly in the UI and rely on the alarm, not the block. Flags are computed at insert time, so editing the list re-flags only from the next snapshot (~15 min); the activity API matches live so the detail view is always current. Reading all users' browser profiles as LocalSystem is a real capability — mitigated by taking host names only, never URLs, titles, or per-user attribution, with 30-day retention and operator-token gating. A large hosts file can slow the Windows DNS Client service, so the block list is capped (10 000 hard, ~5 000 default external extract).
- Privacy stance: only domains cross the wire and are stored — never full URLs, page titles, form data, or which user visited them. Retention matches the rest of telemetry (30 days). The data is visible only to the operator behind the operator token (ADR-0008), and — like all agent-returned data placed in the chat context — is treated as untrusted (ADR-0024).
More Information¶
- Contract:
docs/protocol.md§ Tool catalog, § Telemetry sections, § Versioning (PROTOCOL_VERSION = "0.9"); fixturesdocs/fixtures/request|response_webfilter_{status,apply,clear}.jsonand theweb_activitysection indocs/fixtures/telemetry_snapshot.json. - Code (server):
kenny-server/kenny_server/webfilter.py,store.py(WebFilterStore),tunnel.py(insert-time enrichment),health_rules.py(web_activityrule),webui/__init__.py(webfilter API),tools.py+chat.py(MCP tools),data/webfilter_seed.json. - Code (agent):
kenny-agent/src/telemetry/collectors/web_activity.rs,kenny-agent/src/handlers/webfilter.rs,src/dispatch.rs,src/control.rs. - Related: ADR-0007 (telemetry push + store), ADR-0011 (kill switch), ADR-0020 (safety guard), ADR-0021 (layered list model), ADR-0024 (untrusted agent data).