focus_owner: keep the unhide path reachable, never hide the enforcer

setApplicationHidden drops a package from the default PackageManager
enumeration even though it stays installed=true, so a sweep querying
with flags 0 could never see anything it had already hidden. That made
packagesToShow permanently empty: measured on device, a pass following
one that hid three apps reported "hid 0, restored 0" and left them
hidden with no way back. Query with MATCH_UNINSTALLED_PACKAGES instead.

Hiding the enforcer itself is the other unrecoverable case, since it
takes the escape hatch and the trigger with it. The exporter now injects
com.kuhy.focus_owner into both the day and night allowlists so the
decision layer cannot propose it, and the runner keeps its point-of-use
skip to cover a policy asset rendered before this fix or hand-edited.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YQLwdquDCNmWtvgi3Lk72J
This commit is contained in:
Krzysztof Rudnicki 2026-08-10 15:22:04 +02:00
parent 58c1df573c
commit f33fd03a16
8 changed files with 393 additions and 8 deletions

View File

@ -0,0 +1,17 @@
{
"title": "focus_owner: keep the unhide path reachable and never hide the enforcer",
"objective": "A Device Owner enforcement pass could put the device into a state it could not leave. Hidden packages drop out of the default PackageManager enumeration, so the allowlist sweep could never see them again and nothing could be un-hidden; and hiding the enforcer itself would take the escape hatch and the trigger with it. Both paths are unrecoverable from the device, so both are closed here.",
"acceptance_criteria": [
"installedThirdPartyPackages() queries with MATCH_UNINSTALLED_PACKAGES so already-hidden packages remain visible to the sweep",
"A pass following one that hid apps reports a non-empty packagesToShow and can restore them",
"The exporter injects the enforcer package into both the day and night allowlists, so the decision layer never proposes hiding it",
"EnforcementRunner keeps the point-of-use skip for its own package, covering a policy asset rendered before this fix or hand-edited",
"python_pkg/focus_policy stays at 100% branch coverage, including the new export behaviour"
],
"out_of_scope": [
"Changing which third-party apps the policy blocks",
"The night-curfew schedule itself",
"Any change to Device Owner provisioning or the escape hatch mechanism"
],
"verifier": "python -m pytest python_pkg/focus_policy --cov=python_pkg.focus_policy (123 passed, 100% branch coverage); on-device: a pass that hid three apps previously reported 'hid 0, restored 0' and left them hidden permanently — see focus_owner/docs/profile-owner-verification-2026-08-09.md"
}

View File

@ -0,0 +1,54 @@
{
"intent": "Make it possible for focus_owner to un-hide an app it has hidden. Before this change a hidden app could never be restored, so leaving home (or the curfew ending) would not have given access back.",
"scope": [
"focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/EnforcementRunner.kt",
"python_pkg/focus_policy/export.py",
"python_pkg/focus_policy/tests/test_export.py",
"python_pkg/focus_policy/tests/test_loader.py",
"focus_owner/docs/profile-owner-verification-2026-08-09.md",
"Non-goal: wiring the workout signal into the Kotlin path (pre-existing gap, documented not fixed)",
"Non-goal: Device Owner provisioning, which still needs a factory reset"
],
"changes": [
"EnforcementRunner.installedThirdPartyPackages() now queries with PackageManager.MATCH_UNINSTALLED_PACKAGES. A package hidden by setApplicationHidden is dropped from the default enumeration, so it left installedPackages, so it could never appear in packagesToShow, so it could never be unhidden.",
"export.py injects ENFORCER_PACKAGE (com.kuhy.focus_owner) into both allowlists. The enforcer was in neither, so the decision layer put it in the hide set on every enforcing pass; only an in-app check stopped the app hiding itself along with its own escape hatch.",
"Corrected the EnforcementRunner comment that described that in-app check as redundant defence-in-depth; until a policy asset is regenerated it is the sole protection.",
"Added test_export.py covering the injection guarantee, set-union idempotence, sorted output, curfew rendering and home redaction; updated the test_loader.py allowlist assertion the injection legitimately changes."
],
"verification": [
{
"command": "on-device: managed profile (user 12) with profile owner, tap 'Run enforcement now'",
"result": "fail-then-pass",
"evidence": "Before fix: 'applied LOCATION_UNKNOWN: hid 0, restored 0' with all three apps still hidden=true, and pm list packages --user 12 -3 returning only focus_owner. After fix: 'setApplicationHidden(...,false)=true' x3 and 'applied LOCATION_UNKNOWN: hid 0, restored 3'."
},
{
"command": "adb shell dumpsys package <pkg> (per-user hidden= flag)",
"result": "pass",
"evidence": "com.termux, org.breezyweather, de.duenndns.gmdice all hidden=false after the restore pass; back in the default enumeration; cmd package resolve-activity com.termux resolved to TermuxActivity."
},
{
"command": "adb reboot; re-check hidden state in user 12",
"result": "pass",
"evidence": "All three still hidden=true after reboot. This is the property pm suspend and pm disable-user both fail, and the one the Device Owner design depends on."
},
{
"command": "python -m pytest python_pkg/focus_policy/",
"result": "pass",
"evidence": "123 passed. Isolated coverage for python_pkg/focus_policy: 249 statements, 58 branches, 100%."
},
{
"command": "pre-commit run --files <changed-files>",
"result": "pass",
"evidence": "See commit; all hooks green."
}
],
"risks": [
"MATCH_UNINSTALLED_PACKAGES also returns packages uninstalled with data retained, which could put ghosts in packagesToHide. Measured on this device: pm list packages -3 and -u -3 both return 49, so there are none, and apply() wraps both bridge calls in runCatching so a ghost is a no-op.",
"Verification used a profile owner, not a device owner. The DPM calls are identical, but the harness could not exercise real home coordinates (every pass resolved to LOCATION_UNKNOWN), the full 373-package sweep, or persisted geofence hysteresis.",
"The committed policy asset still predates the exporter fix; it cannot be regenerated on this machine because config_secrets.sh holds the REDACTED_LAT placeholder rather than real coordinates. Until it is regenerated the in-app self-hide check remains the only protection, which is why that check was kept."
],
"rollback": [
"git revert the commit. The Kotlin change is one flag argument and the exporter change is one set union.",
"After rollback, re-check that no app is left hidden: adb shell pm list packages -u -3 --user 0 against pm list packages -3 --user 0. A difference means an app is hidden and, without the fix, cannot be restored by the app itself; clear it with adb shell pm unhide <pkg> or by removing the profile."
]
}

View File

@ -75,9 +75,12 @@ class EnforcementRunner(private val context: Context) {
}
}
for (pkg in decision.packagesToHide) {
// Defence in depth: the policy already refuses to hide these, but
// hiding the launcher or the dialer would be unrecoverable from
// the device itself, so it is checked again at the point of use.
// The exporter injects this package into both allowlists, so the
// decision layer should never propose it. Kept as a belt-and-braces
// check because it guards the unrecoverable case: hiding the
// enforcer takes the escape hatch and the trigger with it, and a
// policy asset rendered before that fix -- or hand-edited -- would
// otherwise hide the app on the first enforcing pass.
if (pkg == context.packageName) continue
if (!bridge.isApplicationHidden(pkg)) {
if (bridge.setApplicationHidden(pkg, true)) hidden++
@ -90,10 +93,21 @@ class EnforcementRunner(private val context: Context) {
)
}
/** Third-party packages, the only ones the allowlist sweep considers. */
/**
* Third-party packages, the only ones the allowlist sweep considers.
*
* [PackageManager.MATCH_UNINSTALLED_PACKAGES] is required, not optional: a
* package hidden by `setApplicationHidden` is dropped from the default
* enumeration entirely, even though it remains `installed=true`. Querying
* with flags `0` therefore returns a set that can never contain anything
* this app has already hidden — so [EnforcementDecision.packagesToShow]
* would come back empty and nothing could ever be unhidden again. Measured
* on device: after a pass hid three apps, the following pass reported
* `hid 0, restored 0` and left them hidden permanently.
*/
private fun installedThirdPartyPackages(): Set<String> {
val pm = context.packageManager
return pm.getInstalledApplications(0)
return pm.getInstalledApplications(PackageManager.MATCH_UNINSTALLED_PACKAGES)
.filter { (it.flags and android.content.pm.ApplicationInfo.FLAG_SYSTEM) == 0 }
.map { it.packageName }
.toSet()

View File

@ -28,11 +28,13 @@
"com.kuhy.diet_guard_app",
"com.kuhy.dufs_client",
"com.kuhy.epopeja_karta",
"com.kuhy.focus_owner",
"com.kuhy.focusstatus",
"com.kuhy.habit_stack",
"com.kuhy.home_inventory",
"com.kuhy.horatio",
"com.kuhy.pomodoro_app",
"com.kuhy.untools",
"com.kuhy.vaultkitbypass",
"com.kuhy.wake_alarm_sync",
"com.kuhy.workout_app",
@ -158,6 +160,7 @@
"com.google.android.contactkeys",
"com.google.android.safetycore",
"com.kuhy.diet_guard_app",
"com.kuhy.focus_owner",
"com.kuhy.focusstatus",
"com.kuhy.habit_stack",
"com.kuhy.vaultkitbypass",

View File

@ -0,0 +1,172 @@
# EnforcementRunner on-device verification (profile-owner harness)
Date: 2026-08-09. Device: Pixel 6a `23181JEGR08034`, Android 16 (SDK 36).
The Device Owner path is still blocked on a factory reset, so this run used a
removable managed profile as a stand-in: `pm create-user --profileOf 0
--managed` (user 12) → `dpm set-profile-owner` → `pm install-existing --user 12`.
Profile owner and device owner reach the same `DevicePolicyManager` calls, so
the wiring under test is identical; what differs is scope, recorded below.
## Verified
| Property | Method | Result |
| ---------------------------------- | ------------------------------------------ | ---------------------------------------------------------------------------------------------------- |
| Policy loads on device | app status screen | 84 allowed (39 night), curfew 23:00-05:00, radius 150 m, 15 workout domains |
| Enforcement pass runs end-to-end | tap "Run enforcement now" | `applied LOCATION_UNKNOWN: hid 3, restored 0` |
| Hide is real, not self-reported | `dumpsys package <pkg>` per-user `hidden=` | `com.termux`, `org.breezyweather`, `de.duenndns.gmdice` all `hidden=true` |
| Hidden app is unreachable | `am start --user 12 -n com.termux/...` | `Error type 3 ... does not exist` |
| Self-hide guard holds | same dumpsys sweep | `com.kuhy.focus_owner` stayed `hidden=false` |
| **Hide survives reboot** | `adb reboot` → re-check | all three still `hidden=true` |
| Next pass is armed | `dumpsys alarm` | `*walarm*:com.kuhy.focus_owner/.EnforcementAlarmReceiver` present |
| **Unhide restores access** | after the fix below | `restored 3`; all `hidden=false`, launchable again |
| Non-exported service rejects shell | `am start-foreground-service` | `Requires permission not exported from uid 1210324` — as designed; the UI button is the only trigger |
The reboot result is the one that matters: it is the property the whole Device
Owner design rests on, and it is the one `pm suspend` and `pm disable-user`
both fail.
## Not covered by this harness
- **The geofence.** Retried after real coordinates were provisioned (see
below), and still not reachable: a freshly created work profile has its own
location cache, which starts empty and stays empty because nothing in the
profile requests a fix. `dumpsys location` showed
`user 10: last location=null` against a valid `52.228948,20.951184` fix in
user 0. `EnforcementRunner.lastKnownLocation()` therefore returned null and
every pass resolved to `LOCATION_UNKNOWN` (fail-closed) regardless. Shell
mock injection is blocked (`SecurityException: not allowed to perform
MOCK_LOCATION`) and `cmd location force-location-update` does not exist on
this build. **The AWAY branch remains unexercised on hardware** and will
only be testable under a real Device Owner in user 0.
Note the earlier `run-as` failure was mine, not a platform limit: `run-as
--user N` works once the app has been launched in user N. Coordinates were
successfully written into the profile's `filesDir` on the second attempt.
- **The full 373-package sweep.** The profile saw only the packages
deliberately installed into it.
- **Persisted hysteresis.** `wasEnforcing()` starts fresh in a new profile.
- **System packages.** `com.google.android.dialer` is a system app, so
`installedThirdPartyPackages()` filters it out; it did not exercise the
allowlist branch as intended.
## The bug this run found: hidden apps could never be restored
Testing the restore path meant widening the allowlist to cover the three test
apps and re-running a pass. Expected `restored 3`. Observed:
```
applied LOCATION_UNKNOWN: hid 0, restored 0
```
with all three still `hidden=true`. The cause is that
`pm list packages --user 12 -3` returned _only_ `com.kuhy.focus_owner` — a
package hidden via `setApplicationHidden` is dropped from the default package
enumeration entirely, while remaining `installed=true hidden=true` and visible
under `pm list packages -u`.
`EnforcementRunner.installedThirdPartyPackages()` called
`getInstalledApplications(0)`. So once an app was hidden it left
`installedPackages`, which meant it could never appear in
`EnforcementDecision.packagesToShow`, which meant **nothing this app hid could
ever be unhidden again** — the exact failure the show-before-hide ordering in
`apply()` exists to prevent. Leaving home would not have restored anything.
Fix: query with `PackageManager.MATCH_UNINSTALLED_PACKAGES`. Re-verified on
device after the fix:
```
setApplicationHidden(de.duenndns.gmdice,false)=true
setApplicationHidden(org.breezyweather,false)=true
setApplicationHidden(com.termux,false)=true
applied LOCATION_UNKNOWN: hid 0, restored 3
```
All three then read `hidden=false`, reappeared in the default enumeration, and
`cmd package resolve-activity com.termux` resolved to `TermuxActivity` again.
The flag also admits packages that are uninstalled with data retained, which
would let `packagesToHide` contain ghosts. Measured on this device:
`pm list packages -3` and `pm list packages -u -3` both return 49, so there are
none. `apply()` wraps both bridge calls in `runCatching` regardless, so a ghost
would be a no-op rather than a crash.
## Second finding: the self-hide guard is not redundant
`EnforcementRunner.kt:79-80` comments the self-hide check as defence in depth —
_"the policy already refuses to hide these"_. Measured against the committed
policy, it does not: `com.kuhy.focus_owner` is absent from `allowed_packages`,
absent from `night_allowed_packages`, and matches none of the 44
`never_disable_prefixes`. The decision layer therefore places the app in
`packagesToHide` on **every** enforcing pass, and the `pkg ==
context.packageName` check at line 81 is the _only_ thing stopping the app from
hiding itself — which would remove the escape hatch and the "Run enforcement
now" trigger together.
Fix belongs in the exporter (`python_pkg/focus_policy/export.py`), not a
hand-edit of the asset, so the guarantee is generated rather than remembered.
The line-81 check should stay either way; only the comment is wrong.
## Wiring gap found
`EnforcementRunner.decide()` hardcodes `workoutActive = false`
(`EnforcementRunner.kt:57`). The decision layer honours the flag
(`EnforcementDecision.kt:83`), but nothing feeds it in the Kotlin path — and
the Kotlin path is the one that runs at alarm time, since a background service
has no Flutter engine. **The workout unblock is currently dead in the deployed
path.** Not fixed in this session.
The Dart side is thinner than it looks. `lib/workout_signal.dart` defines
`ManualWorkoutSignal`, `GuardedWorkoutSignal` and `CombinedWorkoutSignal`, all
unit-tested, but none is constructed anywhere in `lib/` — and there is **no
Health Connect integration at all**: no `androidx.health` dependency, no
`android.permission.health.*` in the manifest, no query. `'health_connect'`
appears only as a fixture string in `test/workout_signal_test.dart`. Wiring
Health Connect is a from-scratch build, not a port.
It is, however, a viable one. Measured on device: `com.stronglifts.app` holds
`android.permission.health.WRITE_EXERCISE`, `WRITE_ACTIVE_CALORIES_BURNED`,
`WRITE_WEIGHT`, `WRITE_HEIGHT` and the matching `READ_*` grants, all
`granted=true`, and `com.google.android.apps.healthdata` is installed. So the
workout data the rooted system used to read out of StrongLifts' SQLite file is
reachable through a supported API, which is the natural replacement for that
lost signal.
## Home coordinates: placeholder found and replaced
`config_secrets.sh` still held `REDACTED_LAT`/`REDACTED_LON`, so the policy
asset could not be regenerated. Real coordinates were taken from the Pixel 6a's
own fix rather than a map lookup — `52.228948, 20.951184`, network provider,
`hAcc=21.7 m`, consistent across the fused and network providers.
That surfaced a second staleness problem: the coordinates already provisioned
into the app's private storage were `52.2297, 21.0122` — Warsaw city centre, a
placeholder. **4156 m from the real location, against a 150 m radius**, so the
geofence would have read as permanently away from home. Re-provisioned via
`scripts/push_home_location.sh` and verified on device.
Regenerating `assets/policy.json` then confirmed the exporter fix end-to-end:
`com.kuhy.focus_owner` now appears in both allowlists. The regeneration also
picked up `com.kuhy.untools`, which was already in `config.sh:365` — the
committed asset was simply stale.
The committed asset stays redacted: `latitude: null`, `longitude: null`, and
zero occurrences of the real values. The unredacted export (stdout only, used
by the provisioning script) carries them.
## Teardown (done)
`pm remove-user 12` → `Success: removed user`; a clean build (committed policy
asset, no widened allowlist) reinstalled into user 0; staged
`/data/local/tmp/home_location.json` removed. Verified afterwards:
- `pm list users` → user 0 only
- `Device Owner Type: -1`, no profile owner
- `com.termux`, `org.breezyweather`, `de.duenndns.gmdice`,
`com.stronglifts.app` all `hidden=false` in user 0
Three `EnforcementAlarmReceiver` alarms remain registered by the user-0
instance. They are inert: `DevicePolicyBridge.setApplicationHidden` returns
early when the caller is neither device owner nor profile owner, which user 0
is not.

View File

@ -19,6 +19,14 @@ if TYPE_CHECKING:
SCHEMA_VERSION = 1
# The enforcing app itself, which must never appear in the hide set. Hiding it
# removes both the escape hatch and the control that triggers the next pass,
# leaving no way back short of a factory reset. The Kotlin runner does check
# this at the point of use, but that check is the *only* protection unless the
# package is also allowed here -- so it is injected into the exported allowlists
# rather than left to a hand-edit of the rendered asset.
ENFORCER_PACKAGE = "com.kuhy.focus_owner"
def policy_to_dict(policy: FocusPolicy) -> dict[str, Any]:
"""Return a JSON-serialisable representation of ``policy``."""
@ -39,8 +47,10 @@ def policy_to_dict(policy: FocusPolicy) -> dict[str, Any]:
},
"curfew": curfew,
"launcher_package": policy.launcher_package,
"allowed_packages": sorted(policy.allowed_packages),
"night_allowed_packages": sorted(policy.night_allowed_packages),
"allowed_packages": sorted({*policy.allowed_packages, ENFORCER_PACKAGE}),
"night_allowed_packages": sorted(
{*policy.night_allowed_packages, ENFORCER_PACKAGE},
),
"never_disable_prefixes": sorted(policy.never_disable_prefixes),
"workout_unblock_domains": sorted(policy.workout_unblock_domains),
"browser_packages": sorted(policy.browser_packages),

View File

@ -0,0 +1,109 @@
"""Tests for the JSON policy exporter.
The guarantee under test is the one that cannot be recovered from on the device
itself: the enforcing app must never be rendered into a policy that would hide
it. Everything else here is shape, not safety.
"""
from __future__ import annotations
import datetime as dt
from python_pkg.focus_policy.export import (
ENFORCER_PACKAGE,
policy_to_dict,
policy_to_json,
)
from python_pkg.focus_policy.model import CurfewWindow, FocusPolicy, HomeLocation
_DEFAULT_CURFEW = CurfewWindow(start=dt.time(23, 0), end=dt.time(5, 0))
def _policy(
*,
# The launcher must be allowed or the model rejects the policy outright.
allowed_packages: frozenset[str] = frozenset({"pl.mbank", "com.launcher"}),
night_allowed_packages: frozenset[str] = frozenset({"pl.mbank", "com.launcher"}),
curfew: CurfewWindow | None = _DEFAULT_CURFEW,
) -> FocusPolicy:
"""Build a policy with the enforcer deliberately absent from both lists."""
return FocusPolicy(
home=HomeLocation(
latitude=52.2297,
longitude=21.0122,
radius_m=150.0,
hysteresis_m=30.0,
),
curfew=curfew,
launcher_package="com.launcher",
allowed_packages=allowed_packages,
night_allowed_packages=night_allowed_packages,
never_disable_prefixes=("com.android.",),
workout_unblock_domains=frozenset({"youtube.com"}),
browser_packages=frozenset({"org.mozilla.firefox"}),
)
def test_enforcer_is_injected_into_both_allowlists() -> None:
"""The app must be allowed even when the source config omits it.
Regression guard for the on-device finding of 2026-08-09: the enforcer was
in neither allowlist, so the decision layer placed it in the hide set on
every enforcing pass, leaving one in-app check as the sole thing preventing
the app from hiding itself.
"""
payload = policy_to_dict(_policy())
assert ENFORCER_PACKAGE in payload["allowed_packages"]
assert ENFORCER_PACKAGE in payload["night_allowed_packages"]
def test_enforcer_is_not_duplicated_when_already_present() -> None:
"""Injection is a set union, so an explicit entry stays single."""
payload = policy_to_dict(
_policy(
allowed_packages=frozenset({"pl.mbank", "com.launcher", ENFORCER_PACKAGE}),
night_allowed_packages=frozenset({"com.launcher", ENFORCER_PACKAGE}),
),
)
assert payload["allowed_packages"].count(ENFORCER_PACKAGE) == 1
assert payload["night_allowed_packages"].count(ENFORCER_PACKAGE) == 1
def test_lists_are_sorted_for_stable_diffs() -> None:
"""Sorted output keeps a committed asset's diff meaningful."""
payload = policy_to_dict(_policy())
for key in (
"allowed_packages",
"night_allowed_packages",
"never_disable_prefixes",
"workout_unblock_domains",
"browser_packages",
):
assert payload[key] == sorted(payload[key])
def test_curfew_is_rendered_as_wall_clock_strings() -> None:
"""Kotlin parses `HH:MM`, not an ISO time."""
assert policy_to_dict(_policy())["curfew"] == {"start": "23:00", "end": "05:00"}
def test_absent_curfew_renders_as_null() -> None:
"""A policy with no curfew must not fabricate one."""
assert policy_to_dict(_policy(curfew=None))["curfew"] is None
def test_redact_home_blanks_coordinates_but_keeps_geometry() -> None:
"""A committed asset discloses the radius, never the location."""
payload = policy_to_json(_policy(), redact_home=True)
assert '"latitude": null' in payload
assert '"longitude": null' in payload
assert '"radius_m": 150.0' in payload
def test_coordinates_survive_when_not_redacted() -> None:
"""The provisioning path still needs the real values."""
assert '"latitude": 52.2297' in policy_to_json(_policy())

View File

@ -218,7 +218,13 @@ class TestExport:
payload = policy_to_dict(load_policy(config_dir / "config.sh"))
assert payload["schema_version"] == 1
assert payload["curfew"] == {"start": "23:00", "end": "05:00"}
assert payload["allowed_packages"] == ["com.good", "com.launcher", "pl.mbank"]
# The exporter injects the enforcer itself; see ENFORCER_PACKAGE.
assert payload["allowed_packages"] == [
"com.good",
"com.kuhy.focus_owner",
"com.launcher",
"pl.mbank",
]
assert payload["home"]["latitude"] == pytest.approx(52.2297)
def test_absent_curfew_serialises_as_null(self, config_dir: Path) -> None: