From bf7b9598937a130816f21cd4da93b038a3b316ff Mon Sep 17 00:00:00 2001 From: ekklesia Date: Sun, 20 Sep 2026 21:04:07 -0500 Subject: [PATCH] feat: the room may use the church Wi-Fi and may not read its password MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These are shared computers in rooms full of people, and in a stock Plasma desktop "use the network" and "read the network's password" are much closer together than they look. Measured on Debian 13 with NetworkManager 1.52 and Plasma 6.3: * org.freedesktop.NetworkManager.settings.modify.own ships as "yes" for an active session — no prompt, no administrator. It covers any connection whose connection.permissions names that user, and a Wi-Fi network added from the desktop is created exactly that way. So a password a teacher types in is readable by the next person to sit down. * the applet offers "Show Network's QR Code" on every saved wireless connection, and the QR code contains the pre-shared key. * that action, and the "Configure…" beside it, are contextual actions in ConnectionItem.qml and do NOT consult authorizeControlModule("kcm_networkmanagement"). The Kiosk key gates the toolbar button only. Hiding buttons was never going to be the answer. Every one of those paths ends at NetworkManager's GetSecrets, so that is where this is answered. A polkit rule refuses settings.modify.own, settings.modify.system, both wifi.share actions and the machine-wide settings, for the classroom user by name. Deliberately NOT refused: network-control, wifi.scan and the enable/disable actions. None returns a secret, and they are what connecting and seeing a network are made of. Automatic reconnection does not go through polkit at all — NetworkManager does it as root — so nothing here can stop the room coming back on the network after a reboot, which is the property that matters most on a Sunday. An administrator's account and root are untouched, and the override route is the same one the sleep rule has. classroom-wifi is the other half, and it is a requirement rather than a convenience: it provisions the church Wi-Fi as a SYSTEM connection, with no connection.permissions, psk-flags=0 and a 0600 root:root file. A connection the machine owns is one nobody in the room can read. Verified against real NetworkManager in the lab — the classroom account gets "Permission denied" on the file, "--" from nmcli and "Insufficient privileges" from GetSecrets, while autoconnect stays on. There is deliberately no teacher-facing join dialog. Anything that lets the room add a connection lets the room own it, and an owned connection is a readable one; docs/networking.md says so rather than leaving it to be rediscovered. The rule is plain JavaScript, so the test evaluates it with polkit's objects stubbed and asserts both halves: which actions come back NO, and which are left alone. What it cannot test is the last mile — polkit resolves a subject through logind and a container has no graphical seat session, so every check there is denied for reasons unrelated to this rule. That one is a physical check, and it is written down as one. Co-Authored-By: Claude Opus 5 --- config/plasma/xdg/kdeglobals | 16 ++ .../org.ekklesia.classroom.network.rules | 86 +++++++++ docs/networking.md | 66 +++++++ scripts/classroom-wifi | 164 ++++++++++++++++++ tests/test-classroom-network.sh | 133 ++++++++++++++ tools/build-release.sh | 12 ++ tools/validate-trixie-release.sh | 14 ++ 7 files changed, 491 insertions(+) create mode 100644 config/polkit/org.ekklesia.classroom.network.rules create mode 100755 scripts/classroom-wifi create mode 100755 tests/test-classroom-network.sh diff --git a/config/plasma/xdg/kdeglobals b/config/plasma/xdg/kdeglobals index b1bb5f3..aef73dd 100644 --- a/config/plasma/xdg/kdeglobals +++ b/config/plasma/xdg/kdeglobals @@ -32,6 +32,22 @@ action/help_report_bug=false action/help_about_kde=false action/help_contents=false +# The network applet reads this one before it draws its own settings +# button and its "Configure Network Connections…" entry — see +# main.qml, which calls +# KAuthorized.authorizeControlModule("kcm_networkmanagement"). +# +# This is only the first of two layers, and on its own it would be +# security theatre. The applet ALSO puts "Show Network's QR Code" and +# "Configure…" on every saved wireless connection as contextual actions, +# and neither of those consults this key — measured on Plasma 6.3. The QR +# code contains the pre-shared key. What actually refuses them is the +# polkit rule in org.ekklesia.classroom.network.rules, which answers at +# NetworkManager rather than in a menu. This key is here so that a teacher +# is not shown doors that will not open. +[KDE Control Module Restrictions][$i] +kcm_networkmanagement=false + # Not immutable. Marking this group [$i] froze the colour scheme so hard # that `classroom-theme light` changed the running session and then lost # the change at the next start — measured. These are defaults for a room diff --git a/config/polkit/org.ekklesia.classroom.network.rules b/config/polkit/org.ekklesia.classroom.network.rules new file mode 100644 index 0000000..d6fd60d --- /dev/null +++ b/config/polkit/org.ekklesia.classroom.network.rules @@ -0,0 +1,86 @@ +// The classroom user cannot be shown a saved Wi-Fi password. +// +// These are shared computers in rooms full of people. The account a +// teacher uses must be able to USE the church's Wi-Fi and must never be +// able to READ its password, and those two are much closer together in a +// stock desktop than they look. +// +// WHY THIS IS NOT DONE IN THE PANEL. +// +// Plasma's network applet offers "Show Network's QR Code" on every saved +// wireless connection — a contextual action in ConnectionItem.qml — and +// that QR code contains the pre-shared key in plain text. Next to it is +// "Configure…", which opens the connection editor. Neither is covered by +// the Kiosk key that hides the applet's own settings button: measured, +// authorizeControlModule("kcm_networkmanagement") gates the toolbar entry +// and not those two. +// +// So hiding buttons cannot be the answer. Every one of those paths ends +// at the same place — NetworkManager's GetSecrets over D-Bus — and polkit +// is where that is answered once, for the applet, the editor, nmcli, a +// future Plasma version and anything else that asks. +// +// WHAT IS REFUSED, AND WHAT IS DELIBERATELY NOT. +// +// Refused: reading or changing stored connections, and the two sharing +// actions that exist to hand the password to somebody. +// +// settings.modify.own is the one that matters most and is the easiest to +// miss. Debian ships it as "yes" — no authentication at all — and it +// applies to any connection whose connection.permissions names this user. +// A Wi-Fi network added from the desktop is created that way by default, +// so a password typed in by a teacher would be readable by the next +// person to sit down. This is why the church's Wi-Fi must be provisioned +// as a SYSTEM connection by an administrator; see classroom-wifi. +// +// Not refused: network-control, wifi.scan and the enable/disable actions. +// Those are what connecting to a network and seeing that it is there are +// made of, and none of them returns a secret. Automatic reconnection does +// not go through polkit at all — NetworkManager does it as root — so +// nothing here can stop the room coming back on the network after a +// reboot. +// +// Scoped to the classroom user by name. A technician's account and root +// are untouched, which is what keeps the machine serviceable: an +// administrator on SSH can still read, edit and add connections normally. +// +// To lift this for maintenance, drop a file of the same name into +// /etc/polkit-1/rules.d/, which polkit reads ahead of this one. + +polkit.addRule(function (action, subject) { + if (subject.user !== "classroom") { + return polkit.Result.NOT_HANDLED; + } + + var refused = [ + // Reading or writing the secrets of a connection this user owns. + // Debian's default for this is "yes". + "org.freedesktop.NetworkManager.settings.modify.own", + // The same for a connection belonging to the system. Debian asks + // for an administrator password here; the classroom has no + // administrator standing at it, and a prompt a teacher cannot + // answer is worse than no menu entry. + "org.freedesktop.NetworkManager.settings.modify.system", + // Handing the password to somebody: the QR code, and the open + // network equivalent. + "org.freedesktop.NetworkManager.wifi.share.protected", + "org.freedesktop.NetworkManager.wifi.share.open", + // Machine-wide settings a classroom has no business changing. + "org.freedesktop.NetworkManager.settings.modify.hostname", + "org.freedesktop.NetworkManager.settings.modify.global-dns", + "org.freedesktop.NetworkManager.reload", + "org.freedesktop.NetworkManager.checkpoint-rollback", + ]; + + for (var i = 0; i < refused.length; i++) { + if (action.id === refused[i]) { + polkit.log( + "ekklesia-classroom: refusing " + action.id + + " for the classroom user" + ); + return polkit.Result.NO; + } + } + + return polkit.Result.NOT_HANDLED; +}); diff --git a/docs/networking.md b/docs/networking.md index 9d8b462..dc27343 100644 --- a/docs/networking.md +++ b/docs/networking.md @@ -40,3 +40,69 @@ This preserves an existing static or ifupdown Ethernet setup. The package disables `NetworkManager-wait-online.service`; no classroom feature is allowed to hold boot waiting for Wi-Fi or Internet access. Saved connections still reconnect automatically. + +## Wi-Fi passwords, and who may see them + +These are shared computers in rooms full of people. The classroom account +has to be able to USE the church Wi-Fi and must never be able to READ its +password. In a stock Plasma desktop those two are much closer together +than they look. + +**Provision Wi-Fi with `classroom-wifi`, as root.** Not from the desktop. + +``` +classroom-wifi "Church Wi-Fi" # prompts, never echoes, never in ps +classroom-wifi --list +classroom-wifi --forget "Old Network" +``` + +This is a requirement, not a convenience. A wireless network added from +the desktop becomes a **user** connection: NetworkManager records +`connection.permissions` naming whoever added it, and Debian allows that +user to read its secrets with **no authentication at all** — +`org.freedesktop.NetworkManager.settings.modify.own` ships as `yes` for an +active session. Plasma's applet then offers *Show Network's QR Code* on +every saved wireless connection, and that QR code contains the pre-shared +key. So a password typed in at the desktop is readable by the next person +to sit down. + +`classroom-wifi` creates a **system** connection instead: no +`connection.permissions`, secrets in a root-only file +(`/etc/NetworkManager/system-connections/*.nmconnection`, 0600 root:root), +`psk-flags=0` so nothing lands in a login keyring. Every account can use +it, and NetworkManager reconnects at boot on its own — no teacher ever +re-enters it. + +### What is refused, and what is deliberately not + +`49-ekklesia-classroom-network.rules` answers at NetworkManager rather +than in a menu, because hiding buttons is not a control: the applet's +*Show Network's QR Code* and *Configure…* are contextual actions that do +**not** consult the Kiosk key which hides its settings button. + +Refused for `classroom`: `settings.modify.own`, `settings.modify.system`, +`wifi.share.protected`, `wifi.share.open`, `settings.modify.hostname`, +`settings.modify.global-dns`, `reload`, `checkpoint-rollback`. + +Left alone on purpose: `network-control`, `wifi.scan`, +`enable-disable-network`, `enable-disable-wifi`. None of them returns a +secret, and they are what connecting and seeing a network are made of. A +rule that protected a password by taking the room off the internet would +not be a trade this room can make. Automatic reconnection does not go +through polkit at all — NetworkManager does it as root — so nothing here +can stop the room coming back after a reboot. + +An administrator's own account and root are untouched. To lift any of it +for maintenance, drop a file of the same name into +`/etc/polkit-1/rules.d/`, which polkit reads first. + +### Recovery when there is no network at all + +There is deliberately no teacher-facing "join a network" dialog in this +release. Anything that lets the room add a connection also lets the room +own it, and an owned connection is a readable one. Recovery is an +administrator running `classroom-wifi` over SSH, or at the machine +through the maintenance terminal chord. If a room ever needs to join a +network unattended, that workflow has to be designed so the connection it +creates still belongs to the machine — not bolted on by relaxing the rule +above. diff --git a/scripts/classroom-wifi b/scripts/classroom-wifi new file mode 100755 index 0000000..6a43b6f --- /dev/null +++ b/scripts/classroom-wifi @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 + +"""Put the church's Wi-Fi on this machine, so the room can use it and +nobody in the room can read it. + +Run by an administrator, as root, over SSH or from the maintenance +terminal: + + classroom-wifi "Church Wi-Fi" # prompts for the password + classroom-wifi --list + classroom-wifi --forget "Old Network" + +WHY THIS EXISTS RATHER THAN LETTING A TEACHER ADD ONE. + +A wireless network added from the desktop is created as a USER connection: +NetworkManager writes connection.permissions naming whoever added it. +Debian then allows that user to read its secrets with no authentication at +all — org.freedesktop.NetworkManager.settings.modify.own is "yes" — so a +password typed in by one teacher is readable by the next person to sit +down, through the applet's own "Show Network's QR Code". + +This creates a SYSTEM connection instead: no connection.permissions, so it +belongs to the machine rather than to a person, its secrets are stored in +a root-only file, and reading them needs an administrator. Every account +on the machine can still USE it, and NetworkManager reconnects to it at +boot on its own without anybody logging in. + +That is the whole reason this is an administrator's command. The classroom +account is refused the NetworkManager actions that return secrets — see +org.ekklesia.classroom.network.rules — and that refusal would be pointless +if the room could create a connection it was allowed to read. +""" + +import argparse +import getpass +import os +import subprocess +import sys + +CONNECTIONS = "/etc/NetworkManager/system-connections" + + +def say(message): + print(f"classroom-wifi: {message}") + + +def nmcli(*args, check=True): + result = subprocess.run( + ["nmcli"] + list(args), capture_output=True, text=True, timeout=60, + check=False, + ) + + if check and result.returncode != 0: + say(result.stderr.strip() or "nmcli failed") + raise SystemExit(1) + + return result + + +def listing(): + result = nmcli("-t", "-f", "NAME,TYPE,UUID", "connection", "show") + + for line in result.stdout.splitlines(): + parts = line.split(":") + + if len(parts) >= 2 and "wireless" in parts[1]: + print(f" {parts[0]}") + + return 0 + + +def forget(name): + nmcli("connection", "delete", name) + say(f"removed {name}") + + return 0 + + +def add(ssid, password, name=None): + name = name or ssid + + # Deliberately no connection.permissions. That single omission is the + # difference between a connection the machine owns and one a teacher + # owns and may therefore read. + arguments = [ + "connection", "add", + "type", "wifi", + "con-name", name, + "ifname", "*", + "ssid", ssid, + "connection.autoconnect", "yes", + # Stored in the connection file, which is root-only, rather than + # in a login keyring that belongs to whoever is signed in. + "802-11-wireless-security.psk-flags", "0", + ] + + if password: + arguments += [ + "wifi-sec.key-mgmt", "wpa-psk", + "wifi-sec.psk", password, + ] + + nmcli(*arguments) + + path = os.path.join(CONNECTIONS, f"{name}.nmconnection") + + if os.path.exists(path): + # NetworkManager already writes 0600 root:root. Said out loud and + # enforced anyway, because this is the file the whole arrangement + # rests on. + os.chmod(path, 0o600) + os.chown(path, 0, 0) + mode = oct(os.stat(path).st_mode & 0o777) + say(f"{path} is {mode} root:root") + + say(f"added {name} as a system connection") + say("the room can use it; the classroom account cannot read it") + + return 0 + + +def main(): + parser = argparse.ArgumentParser( + description="Provision the church Wi-Fi as a system connection.") + parser.add_argument("ssid", nargs="?", help="the network name") + parser.add_argument("--name", help="connection name, if not the SSID") + parser.add_argument("--list", action="store_true", + help="list the wireless connections on this machine") + parser.add_argument("--forget", metavar="NAME", + help="remove a saved connection") + parser.add_argument("--open", action="store_true", + help="the network has no password") + arguments = parser.parse_args() + + if arguments.list: + return listing() + + if os.geteuid() != 0: + say("run this as root; it creates a connection the machine owns") + return 1 + + if arguments.forget: + return forget(arguments.forget) + + if not arguments.ssid: + parser.print_help() + return 2 + + password = "" + + if not arguments.open: + # Never echoed, and never read from the command line, where it + # would sit in a shell history and in ps. + password = getpass.getpass("Wi-Fi password (not shown): ") + + if not password: + say("no password given") + return 1 + + return add(arguments.ssid, password, arguments.name) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test-classroom-network.sh b/tests/test-classroom-network.sh new file mode 100755 index 0000000..d325ec8 --- /dev/null +++ b/tests/test-classroom-network.sh @@ -0,0 +1,133 @@ +#!/bin/bash +# What the classroom account may and may not do to the network. +# +# The rule is plain JavaScript that polkitd evaluates, so it is evaluated +# here too, with polkit's own objects stubbed. That tests the part this +# package actually wrote: which action ids come back NO, for which user, +# and — just as important — which ones are deliberately left alone, +# because a rule that refuses too much takes the room off the network. +# +# What this canNOT test is the last mile: that a teacher sitting in a real +# Plasma session cannot reveal a saved password through the applet. polkit +# resolves a subject through logind, and a container has no graphical seat +# session for it to resolve, so every check here comes back denied for +# reasons that have nothing to do with this rule. docs/testing.md carries +# the physical procedure for that. +set -uo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +if ! command -v node >/dev/null 2>&1; then + echo " SKIP node is not installed; the polkit rule was not evaluated" + exit 0 +fi + +RULES="$ROOT/config/polkit/org.ekklesia.classroom.network.rules" +SLEEP_RULES="$ROOT/config/polkit/org.ekklesia.classroom.sleep.rules" +KDEGLOBALS="$ROOT/config/plasma/xdg/kdeglobals" +WIFI="$ROOT/scripts/classroom-wifi" + +node - "$RULES" "$SLEEP_RULES" "$KDEGLOBALS" "$WIFI" <<'JS' +const fs = require("fs"); +const [rulesPath, sleepPath, kdeglobalsPath, wifiPath] = process.argv.slice(2); + +let passed = 0, failed = 0; +function check(label, condition, detail) { + if (condition) { console.log(" ok " + label); passed++; } + else { console.log(" FAIL " + label); if (detail) console.log(" " + detail); failed++; } +} + +// polkit's own surface, as far as these rules use it. +function loadRules(path) { + const handlers = []; + const polkit = { + Result: {NO: "NO", YES: "YES", NOT_HANDLED: "NOT_HANDLED", + AUTH_ADMIN: "AUTH_ADMIN"}, + addRule: (fn) => handlers.push(fn), + log: () => {}, + }; + const source = fs.readFileSync(path, "utf8"); + new Function("polkit", source)(polkit); + return (actionId, user) => { + for (const handler of handlers) { + const result = handler({id: actionId}, {user: user}); + if (result !== polkit.Result.NOT_HANDLED) return result; + } + return "NOT_HANDLED"; + }; +} + +const decide = loadRules(rulesPath); + +console.log("1. the classroom account cannot be shown a saved password"); +// settings.modify.own is the one that matters. Debian ships it as "yes" +// for an active session: no prompt, no administrator, just the password. +// A network added from the desktop is user-owned, so this is the path a +// teacher would actually have. +check("a connection this user owns is refused", + decide("org.freedesktop.NetworkManager.settings.modify.own", "classroom") === "NO"); +check("a connection the system owns is refused", + decide("org.freedesktop.NetworkManager.settings.modify.system", "classroom") === "NO"); +check("the QR code that contains the pre-shared key is refused", + decide("org.freedesktop.NetworkManager.wifi.share.protected", "classroom") === "NO"); +check("and its open-network twin", + decide("org.freedesktop.NetworkManager.wifi.share.open", "classroom") === "NO"); + +console.log("2. the room stays on the network"); +// Refusing these would be a rule that protects a password by taking the +// classroom off the internet, which is not a trade this room can make. +for (const action of ["network-control", "wifi.scan", + "enable-disable-network", "enable-disable-wifi"]) { + check(action + " is left alone", + decide("org.freedesktop.NetworkManager." + action, "classroom") === "NOT_HANDLED"); +} + +console.log("3. an administrator keeps every one of them"); +for (const user of ["tech", "root"]) { + check(user + " is not touched by this rule", + ["settings.modify.own", "settings.modify.system", + "wifi.share.protected"].every( + a => decide("org.freedesktop.NetworkManager." + a, user) === "NOT_HANDLED")); +} + +console.log("4. machine-wide settings a room has no business changing"); +for (const action of ["settings.modify.hostname", "settings.modify.global-dns", + "reload", "checkpoint-rollback"]) { + check(action + " is refused", + decide("org.freedesktop.NetworkManager." + action, "classroom") === "NO"); +} + +console.log("5. the sleep rule is unchanged by any of this"); +const sleepDecide = loadRules(sleepPath); +check("the classroom still cannot suspend the room", + sleepDecide("org.freedesktop.login1.suspend", "classroom") === "NO"); +check("and a technician still can", + sleepDecide("org.freedesktop.login1.suspend", "tech") === "NOT_HANDLED"); +check("the network rule does not answer sleep", + decide("org.freedesktop.login1.suspend", "classroom") === "NOT_HANDLED"); + +console.log("6. the panel is told not to offer doors that will not open"); +const kdeglobals = fs.readFileSync(kdeglobalsPath, "utf8"); +check("the network settings module is refused by Kiosk policy", + /\[KDE Control Module Restrictions\]\[\$i\][\s\S]*?kcm_networkmanagement=false/.test(kdeglobals)); +check("and the restriction is immutable, like the rest of the policy", + /\[KDE Control Module Restrictions\]\[\$i\]/.test(kdeglobals)); + +console.log("7. the Wi-Fi an administrator provisions belongs to the machine"); +const wifi = fs.readFileSync(wifiPath, "utf8"); +// As a quoted nmcli argument, which is the only form that would actually +// set it. The prose above it in the script says the word on purpose. +check("no connection.permissions is passed to nmcli, so it is the machine's", + !/"connection\.permissions"/.test(wifi), + "a permissions argument would make the secrets readable by that user"); +check("secrets are stored in the connection file, not a login keyring", + /psk-flags[\s\S]{0,40}"0"/.test(wifi)); +check("the password is prompted for, never taken from the command line", + /getpass\.getpass/.test(wifi) && !/--psk/.test(wifi)); +check("and it refuses to run as anybody but root", + /geteuid\(\) != 0/.test(wifi)); + +console.log(); +console.log(passed + " passed, " + failed + " failed"); +process.exit(failed ? 1 : 0); +JS diff --git a/tools/build-release.sh b/tools/build-release.sh index 43f730f..ee4b747 100755 --- a/tools/build-release.sh +++ b/tools/build-release.sh @@ -128,6 +128,12 @@ install -Dm755 \ "$ROOT/scripts/classroom-printer-setup" \ "$STAGE/usr/local/bin/classroom-printer-setup" +# An administrator's command. The church Wi-Fi has to be provisioned as a +# system connection or the classroom account can read its password. +install -Dm755 \ + "$ROOT/scripts/classroom-wifi" \ + "$STAGE/usr/local/bin/classroom-wifi" + # Shared helpers imported by the classroom commands. install -Dm644 \ "$ROOT/lib/classroom_config.py" \ @@ -411,6 +417,12 @@ install -Dm644 \ "$ROOT/config/polkit/org.ekklesia.classroom.sleep.rules" \ "$STAGE/usr/share/polkit-1/rules.d/49-ekklesia-classroom-sleep.rules" +# The classroom user cannot be shown a saved Wi-Fi password. Same place +# and the same override route as the sleep rule above. +install -Dm644 \ + "$ROOT/config/polkit/org.ekklesia.classroom.network.rules" \ + "$STAGE/usr/share/polkit-1/rules.d/49-ekklesia-classroom-network.rules" + install -Dm644 \ "$ROOT/config/plasma/autostart/ekklesia-classroom-scale.desktop" \ "$STAGE/etc/xdg/autostart/ekklesia-classroom-scale.desktop" diff --git a/tools/validate-trixie-release.sh b/tools/validate-trixie-release.sh index 82b42a8..e7ef885 100755 --- a/tools/validate-trixie-release.sh +++ b/tools/validate-trixie-release.sh @@ -118,6 +118,20 @@ grep -q 'pipewire-pulse' "$audio_unit" command -v classroom-audio >/dev/null command -v pactl >/dev/null +# The classroom account cannot be shown a saved Wi-Fi password. The rule +# has to be installed, owned by root — polkit silently ignores a rules +# file that is not — and it has to refuse the action Debian ships as +# "yes", which is the one a teacher would actually reach. +network_rules=/usr/share/polkit-1/rules.d/49-ekklesia-classroom-network.rules +[[ -f "$network_rules" ]] +[[ "$(stat -c %U:%G "$network_rules")" == "root:root" ]] +grep -q "settings.modify.own" "$network_rules" +grep -q "wifi.share.protected" "$network_rules" +# And it must NOT refuse the actions that keep the room on the network. +! grep -qE '"org\.freedesktop\.NetworkManager\.network-control"' "$network_rules" +grep -q "kcm_networkmanagement=false" /etc/ekklesia-classroom/xdg/kdeglobals +command -v classroom-wifi >/dev/null + # The policy module and the daemon agree on what they import, on a machine # rather than in a checkout. PYTHONPATH=/usr/local/lib/ekklesia-classroom python3 -c \