diff --git a/docs/superpowers/evidence/always-on-vpn-network-block-2026-08-10.json b/docs/superpowers/evidence/always-on-vpn-network-block-2026-08-10.json new file mode 100644 index 00000000..1b57e6fe --- /dev/null +++ b/docs/superpowers/evidence/always-on-vpn-network-block-2026-08-10.json @@ -0,0 +1,55 @@ +{ + "intent": "Add the network-level layer: pin RethinkDNS as the device owner's always-on VPN so YouTube is blocked by host, in every app, not only by hiding the YouTube package.", + "scope": [ + "python_pkg/focus_policy/export.py", + "python_pkg/focus_policy/tests/test_export.py", + "focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/DevicePolicyBridge.kt", + "focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/EnforcementRunner.kt", + "focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/MainActivity.kt", + "phone_focus_mode/config.sh", + "focus_owner/assets/policy.json", + "Non-goal: DISALLOW_CONFIG_VPN, deliberately deferred until the filter is confirmed working", + "Non-goal: the domain rules themselves, which cannot be scripted" + ], + "changes": [ + "DevicePolicyBridge.setAlwaysOnVpn uses DevicePolicyManager.setAlwaysOnVpnPackage. This is not the mechanism that failed on 2026-08-09: that was `settings put secure always_on_vpn_app`, which writes, survives a reboot, and is ignored by VpnManager. The DPM call from a device owner actually takes effect.", + "lockdownEnabled=false deliberately. Lockdown drops all traffic when the tunnel is down, and the wipe checklist names lockdown plus the DISALLOW_* restrictions as the combination most likely to produce an unrecoverable device.", + "releaseDeviceOwner now also clears DISALLOW_CONFIG_VPN and unpins the VPN before clearDeviceOwnerApp, for the same reason it already unblocked uninstall: these are device-owner powers, so clearing ownership first would strand them with nothing privileged left to lift them.", + "The pin is re-asserted every pass from EnforcementRunner.apply, unconditionally rather than tracking decision.isEnforcing -- the network filter must not lift when the geofence says AWAY.", + "always_on_vpn_package is emitted by the exporter only when the provider is also in never_disable_prefixes, and com.celzero.bravedns was added there. Pinning a package the sweep can hide is the worst case: the restriction would point at a hidden app and cost general connectivity, not just the filter." + ], + "verification": [ + { + "command": "pytest python_pkg/focus_policy/ --cov", + "result": "pass", + "evidence": "129 tests, 100% branch coverage retained on every module. The new test asserts the field is empty when the provider is unprotected and set when it is." + }, + { + "command": "dumpsys package com.celzero.bravedns | grep BIND_VPN_SERVICE", + "result": "pass", + "evidence": "com.celzero.bravedns/.service.BraveVPNService declares BIND_VPN_SERVICE, so it is a valid always-on target rather than a package that would silently no-op." + }, + { + "command": "run an enforcement pass, then dumpsys device_policy", + "result": "pass", + "evidence": "logcat 'FocusOwner: alwaysOnVpn -> com.celzero.bravedns'; dumpsys reports mAlwaysOnVpnPackage=com.celzero.bravedns, mAlwaysOnVpnLockdown=false. RethinkDNS was also unhidden by the same pass, confirming the never-disable entry works." + }, + { + "command": "ip addr show tun0 ; pidof com.celzero.bravedns", + "result": "partial", + "evidence": "The app is running (pid 8871) and the pin is registered, but there is NO tun0 interface -- the tunnel has not come up. Android requires a one-time in-app VPN consent tap that cannot be granted over adb. The pin is armed but the filter is not yet carrying traffic." + } + ], + "risks": [ + "THE NETWORK BLOCK IS NOT YET ACTIVE. The pin is set but the tunnel is down pending the VPN consent tap, and RethinkDNS is at first-run state: the 8 wildcard YouTube rules died with the wipe. They live in app-private storage, so phone_backup.sh never captured them and adb cannot restore them -- they must be re-entered by hand.", + "Pinning RethinkDNS consumes Android's single VPN slot, so com.zaneschepke.wireguardautotunnel cannot run, and dns.kuhy.duckdns.org (bound to 10.8.0.1, WireGuard-only) is unreachable. The user chose this trade explicitly.", + "DISALLOW_CONFIG_VPN is NOT applied yet, so the user can still turn the VPN off from Settings. Applying it before the filter is confirmed working would lock in a non-functional configuration.", + "Whether DISALLOW_CONFIG_VPN also locks RethinkDNS's own in-app rule editor is untested. If it does, every domain rule must be final before the restriction is applied.", + "The always-on pin has not been reboot-tested." + ], + "rollback": [ + "In-app 'Release device owner' unpins the VPN and clears the restriction before releasing, all in one action.", + "To unpin without releasing: remove com.celzero.bravedns from SYSTEM_NEVER_DISABLE, regenerate the asset (the exporter then emits an empty always_on_vpn_package), rebuild, install, run a pass.", + "adb shell dpm set-always-on-vpn is not a rollback path; the DPM state is owned by the app." + ] +} diff --git a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/DevicePolicyBridge.kt b/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/DevicePolicyBridge.kt index 8d6d454c..ad631248 100644 --- a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/DevicePolicyBridge.kt +++ b/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/DevicePolicyBridge.kt @@ -98,6 +98,66 @@ class DevicePolicyBridge(private val context: Context) { .getOrDefault(false) } + /** + * Pins [vpnPackage] as the always-on VPN, the network-level block. + * + * This is the DPM call, not `settings put secure always_on_vpn_app` -- + * that one writes successfully, survives a reboot, and does nothing, + * because VpnManager ignores adb-written values. Measured on this device. + * + * `lockdownEnabled` is false deliberately. Lockdown drops all traffic + * whenever the tunnel is down, and the wipe checklist names it, together + * with the DISALLOW_* restrictions, as the combination most likely to + * leave an unrecoverable device. The filter being bypassable during a + * VPN outage is the lesser problem. + * + * @return null on success, or a message describing why it failed. + */ + fun setAlwaysOnVpn(vpnPackage: String): String? { + if (!isDeviceOwner()) return "not device owner" + return runCatching { + dpm.setAlwaysOnVpnPackage(admin, vpnPackage, false) + val active = dpm.getAlwaysOnVpnPackage(admin) + Log.i(FocusDeviceAdminReceiver.TAG, "alwaysOnVpn -> $active") + if (active == vpnPackage) null else "pinned $active, expected $vpnPackage" + }.getOrElse { error -> + // UnsupportedOperationException is the documented signal that the + // package does not declare a VPN service, or does not opt in to + // always-on. Report it rather than leaving a silent no-op. + Log.e(FocusDeviceAdminReceiver.TAG, "setAlwaysOnVpnPackage failed", error) + error.message ?: error::class.java.simpleName + } + } + + /** The package currently pinned as always-on VPN, or null. */ + fun alwaysOnVpnPackage(): String? { + if (!isDeviceOwner()) return null + return runCatching { dpm.getAlwaysOnVpnPackage(admin) }.getOrNull() + } + + /** + * Prevents the user from changing VPN configuration. + * + * Applied separately from [setAlwaysOnVpn] and only after it is confirmed + * working: pinning the restriction first would make a failed VPN setup + * harder to repair from the device. + */ + fun setVpnConfigBlocked(blocked: Boolean): Boolean { + if (!isDeviceOwner()) return false + return runCatching { + if (blocked) { + dpm.addUserRestriction(admin, android.os.UserManager.DISALLOW_CONFIG_VPN) + } else { + dpm.clearUserRestriction(admin, android.os.UserManager.DISALLOW_CONFIG_VPN) + } + Log.i(FocusDeviceAdminReceiver.TAG, "DISALLOW_CONFIG_VPN=$blocked") + true + }.getOrElse { error -> + Log.e(FocusDeviceAdminReceiver.TAG, "DISALLOW_CONFIG_VPN failed", error) + false + } + } + /** * Describes provisioning state for the UI, so the escape hatch can be found * without a PC attached. @@ -109,6 +169,7 @@ class DevicePolicyBridge(private val context: Context) { "sdkInt" to Build.VERSION.SDK_INT, "restrictionsApplied" to false, "uninstallBlocked" to isSelfUninstallBlocked(), + "alwaysOnVpn" to (alwaysOnVpnPackage() ?: ""), ) /** @@ -131,11 +192,17 @@ class DevicePolicyBridge(private val context: Context) { return true } return runCatching { - // Order matters and is not interchangeable: uninstall blocking is a - // device-owner power, so clearing ownership first would leave the - // block in place with nothing privileged left to lift it — an app - // that owns nothing and cannot be removed. Unblock, then release. + // Order matters and is not interchangeable: every one of these is a + // device-owner power, so clearing ownership first would leave them + // in place with nothing privileged left to lift them — an app that + // owns nothing and cannot be removed, and a pinned VPN nobody can + // reconfigure. Undo the restrictions, then release. setSelfUninstallBlocked(false) + setVpnConfigBlocked(false) + runCatching { dpm.setAlwaysOnVpnPackage(admin, null, false) } + .onFailure { + Log.w(FocusDeviceAdminReceiver.TAG, "could not unpin VPN", it) + } dpm.clearDeviceOwnerApp(context.packageName) val released = !isDeviceOwner() Log.i(FocusDeviceAdminReceiver.TAG, "clearDeviceOwnerApp -> released=$released") diff --git a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/EnforcementRunner.kt b/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/EnforcementRunner.kt index 96ec319c..444d82cd 100644 --- a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/EnforcementRunner.kt +++ b/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/EnforcementRunner.kt @@ -67,6 +67,23 @@ class EnforcementRunner(private val context: Context) { * that restores access rather than the half that removes it. */ fun apply(decision: EnforcementDecision, bridge: DevicePolicyBridge) { + // Re-read rather than threaded through from decide(): an unreadable + // policy must not stop a pass that is otherwise ready to apply, and + // the VPN pin is the one step that should survive a policy problem. + val policy = runCatching { FocusPolicy.load(context) }.getOrNull() + + // Re-pinned every pass, unconditionally rather than tracking the + // decision: the network filter is the layer that reaches youtube.com + // in Firefox, in a webview, and in clients nobody has enumerated, so + // it must not lift when the geofence says AWAY. DISALLOW_CONFIG_VPN is + // deliberately NOT applied here -- it is a separate, later step, so a + // misconfigured VPN stays repairable from the device. + policy?.alwaysOnVpnPackage?.let { vpn -> + bridge.setAlwaysOnVpn(vpn)?.let { failure -> + Log.w(FocusDeviceAdminReceiver.TAG, "always-on VPN not pinned: $failure") + } + } + // Re-asserted every pass rather than set once at provisioning time, so // the protection self-heals: `adb uninstall` while enforcing is the one // route that strands device ownership with no holder, and a factory diff --git a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/FocusPolicy.kt b/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/FocusPolicy.kt index ee5b76ae..8d2db225 100644 --- a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/FocusPolicy.kt +++ b/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/FocusPolicy.kt @@ -81,6 +81,13 @@ data class FocusPolicy( * `packagesToShow`. */ val alwaysBlockedPackages: Set = emptySet(), + /** + * Package pinned as the always-on VPN, or null when none is configured. + * + * The exporter emits this only when the package is also protected from + * the sweep, so the enforcer can never hide the app it has pinned. + */ + val alwaysOnVpnPackage: String? = null, ) { /** * Whether a package must never be hidden. @@ -165,6 +172,10 @@ data class FocusPolicy( workoutUnblockDomains = json.stringSet("workout_unblock_domains"), blockableSystemPackages = json.optionalStringSet("blockable_system_packages"), alwaysBlockedPackages = json.optionalStringSet("always_blocked_packages"), + // Empty string means "not configured", which is what the + // exporter writes when the provider is not sweep-protected. + alwaysOnVpnPackage = json.optString("always_on_vpn_package") + .takeIf { it.isNotEmpty() }, curfew = curfewJson?.let { CurfewWindow( startMinutes = parseHhMm(it.getString("start"), "curfew.start"), diff --git a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/MainActivity.kt b/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/MainActivity.kt index 00b1029c..b09be59a 100644 --- a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/MainActivity.kt +++ b/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/MainActivity.kt @@ -14,6 +14,24 @@ class MainActivity : FlutterActivity() { when (call.method) { "status" -> result.success(bridge.status()) "releaseDeviceOwner" -> result.success(bridge.releaseDeviceOwner()) + "setAlwaysOnVpn" -> { + val pkg = call.argument("package") + if (pkg == null) { + result.error("bad_args", "package required", null) + } else { + // Returns the failure message, or null on success, + // so the UI can say why rather than just "failed". + result.success(bridge.setAlwaysOnVpn(pkg)) + } + } + "setVpnConfigBlocked" -> { + val blocked = call.argument("blocked") + if (blocked == null) { + result.error("bad_args", "blocked required", null) + } else { + result.success(bridge.setVpnConfigBlocked(blocked)) + } + } "setSelfUninstallBlocked" -> { val blocked = call.argument("blocked") if (blocked == null) { diff --git a/focus_owner/assets/policy.json b/focus_owner/assets/policy.json index 91f410e9..98d02bf9 100644 --- a/focus_owner/assets/policy.json +++ b/focus_owner/assets/policy.json @@ -93,6 +93,7 @@ "com.google.android.apps.youtube.music", "com.google.android.youtube" ], + "always_on_vpn_package": "com.celzero.bravedns", "blockable_system_packages": [ "com.android.chrome", "com.android.vending", @@ -145,6 +146,7 @@ "com.android.systemui", "com.android.vpndialogs", "com.android.wifi", + "com.celzero.bravedns", "com.google.android.apps.messaging", "com.google.android.contacts", "com.google.android.deskclock", diff --git a/phone_focus_mode/config.sh b/phone_focus_mode/config.sh index 4a967849..43938333 100755 --- a/phone_focus_mode/config.sh +++ b/phone_focus_mode/config.sh @@ -614,6 +614,11 @@ export BLOCKED_SYSTEM_APPS=" # curfew split, so it holds under every condition. export SYSTEM_NEVER_DISABLE=" pl.infakt.infakt +# The always-on VPN provider. Hiding it is self-defeating in the worst way: +# it is the network-level YouTube block, and with DISALLOW_CONFIG_VPN pinned +# to a hidden package the device can lose general connectivity rather than +# just the filter. Must outrank every enforcement branch. +com.celzero.bravedns com.android.launcher com.android.launcher3 com.android.settings diff --git a/python_pkg/focus_policy/export.py b/python_pkg/focus_policy/export.py index b757dae0..883d08a5 100644 --- a/python_pkg/focus_policy/export.py +++ b/python_pkg/focus_policy/export.py @@ -76,6 +76,14 @@ ALWAYS_BLOCKED_PACKAGES = frozenset( # the sweep by also being opted in above. Listing one without the other would # not error -- it would silently never be hidden, because the runner filters it # out of installedPackages before the decision layer sees it. +# The always-on VPN provider, pinned by the device owner on every pass. +# +# This is the network-level block: package hiding stops the YouTube app, but +# only a VPN reaches youtube.com in Firefox, in a webview, or in any client +# that has not been thought of. Named here rather than hardcoded in Kotlin so +# the asset stays the single description of the policy. +ALWAYS_ON_VPN_PACKAGE = "com.celzero.bravedns" + _UNSWEEPABLE = ALWAYS_BLOCKED_PACKAGES - BLOCKABLE_SYSTEM_PACKAGES if _UNSWEEPABLE: # pragma: no cover - guards a constant, not a code path msg = ( @@ -124,6 +132,13 @@ def policy_to_dict(policy: FocusPolicy) -> dict[str, Any]: "always_blocked_packages": sorted( ALWAYS_BLOCKED_PACKAGES - {*policy.allowed_packages, ENFORCER_PACKAGE}, ), + # Emitted only when the provider is protected from the sweep. Pinning a + # package the enforcer can hide is the worst case: DISALLOW_CONFIG_VPN + # points at a hidden app and the device loses connectivity rather than + # just the filter. + "always_on_vpn_package": ( + ALWAYS_ON_VPN_PACKAGE if policy.is_protected(ALWAYS_ON_VPN_PACKAGE) else "" + ), } diff --git a/python_pkg/focus_policy/tests/test_export.py b/python_pkg/focus_policy/tests/test_export.py index a8c09249..44459dfb 100644 --- a/python_pkg/focus_policy/tests/test_export.py +++ b/python_pkg/focus_policy/tests/test_export.py @@ -25,6 +25,7 @@ def _policy( allowed_packages: frozenset[str] = frozenset({"pl.mbank", "com.launcher"}), night_allowed_packages: frozenset[str] = frozenset({"pl.mbank", "com.launcher"}), curfew: CurfewWindow | None = _DEFAULT_CURFEW, + never_disable_prefixes: tuple[str, ...] = ("com.android.",), ) -> FocusPolicy: """Build a policy with the enforcer deliberately absent from both lists.""" return FocusPolicy( @@ -38,7 +39,7 @@ def _policy( launcher_package="com.launcher", allowed_packages=allowed_packages, night_allowed_packages=night_allowed_packages, - never_disable_prefixes=("com.android.",), + never_disable_prefixes=never_disable_prefixes, workout_unblock_domains=frozenset({"youtube.com"}), browser_packages=frozenset({"org.mozilla.firefox"}), ) @@ -119,6 +120,22 @@ def test_always_blocked_exempts_youtube_and_chrome_from_the_geofence() -> None: assert "com.android.vending" not in always +def test_always_on_vpn_requires_a_sweep_protected_provider() -> None: + """Pinning a package the enforcer can hide is the worst failure mode. + + DISALLOW_CONFIG_VPN pointing at a hidden app costs general connectivity, + not just the filter, so the field is emitted only when the provider is + protected. + """ + unprotected = policy_to_dict(_policy())["always_on_vpn_package"] + protected = policy_to_dict( + _policy(never_disable_prefixes=("com.celzero.bravedns",)), + )["always_on_vpn_package"] + + assert unprotected == "" + assert protected == "com.celzero.bravedns" + + def test_always_blocked_is_a_subset_of_blockable() -> None: """An always-blocked system app the sweep cannot see would silently no-op.