Device policies
Note (v0.2): HardenedOS used to ship three device policy tiers (Personal / Corporate / Government). Operationally that ladder created confusion (which tier am I?) without buying anything the per-policy model couldn't deliver. v0.2 collapsed it: there's now one policy capability set, every reseller can set every policy, and customers compose their fleet's profile from a single menu. Legacy tier values (
personal,corporate,government) remain valid on existing rows for backward compatibility — they all resolve to the same universal capability set.The file kept its name (
policy-tiers.md) so existing references in commit messages and code comments don't break.
HardenedOS exposes a single policy capability set that every device manifest may draw from. Resellers choose, per-policy, which ones to enforce on their fleet. Defaults are restrictive about privacy (lock-screen, biometric, no silent surveillance) and permissive about everything else (the device starts unmanaged beyond those defaults).
This document is the authoritative reference for what policies exist. The manifest schema, server-side validation rules, and DPC enforcement all derive from this document.
Policies apply on both deployment tiers (managed + Custom OS). Almost every key uses standard Android Enterprise APIs and works on any OEM device; a small number are GrapheneOS-specific and silently no-op elsewhere — the policy-key compatibility matrix marks each one.
The privacy floor — what every device guarantees
Non-negotiable across every device, regardless of which policies the reseller has enabled:
- Settings → Device Management is always visible. Users can see at any time: their reseller, every active policy in plain language, what telemetry flows to the reseller. The reseller cannot hide this panel.
- Encryption at rest is always on. Never disable-able by any policy.
- Verified boot is always on. Never disable-able by any policy. On the managed tier this is the OEM's verified boot (keyed to the OEM); the Custom OS tier provides verified boot keyed to HardenedOS with a re-locked bootloader.
- Android sandbox is preserved. No app, including the DPC, can escape the standard Android security model.
- The user's data is the user's. Files, photos, contacts, messages encrypted at rest, never streamed to reseller without explicit opt-in per data category, and even then only in specifically permitted forms (e.g., aggregate health metrics for fleet management, never message contents).
- Factory reset cannot be permanently disabled. Resellers may gate factory reset behind an admin-controlled credential, but cannot remove the option entirely. (Compliance with EU "right to dispose" and similar regulations.)
Fleet-management telemetry — what the reseller does receive
Distinct from user content (above), a managed device reports a small set of fleet-management signals on each heartbeat. These are operational, disclosed in plain language in Settings → Device Management, and necessary to administer a locked-down fleet:
- Device metadata — OS version, channel, battery, free storage.
- Installed-app inventory — the full package list
(name, version, signing-cert SHA-256, system flag). Lets the reseller
confirm a force-installed app actually landed, a blocked app is gone,
and spot unexpected user-installed apps. Surfaced via
GET /partner/v1/devices/{id}/apps, reconciled against policy. - Aggregate data usage — total bytes moved, split only by network type (mobile vs wifi) over a window. Per-app traffic attribution is never collected — that would be a behavioural profile and sits on the wrong side of the surveillance ceiling.
This is the boundary the "explicit opt-in per data category"
guarantee draws: app inventory and aggregate byte
counts are management data a managed device exposes; message contents,
browsing, location, and per-app behaviour are not. See
api/internal/device (ingest) and
api/internal/partner/installed_apps_inventory.go (read +
reconcile).
The surveillance ceiling — what no policy can enable
Forbidden no matter what. The schema validator rejects any manifest
containing these as true. The DPC validates manifests on
receipt and refuses to apply.
| Forbidden | Why |
|---|---|
| Audio recording (mic capture by an admin service) | No legitimate MDM use case; pure surveillance. |
| Silent screen capture (screenshot mechanism not initiated by user) | Captures user content without consent. |
| Keystroke logging | Captures passwords, private messages, credit cards. |
| Personal communication content extraction (SMS, email, chat contents) | Brand-defining: HardenedOS doesn't snoop. |
| Browser history capture | Surveillance, not management. |
| Camera capture without explicit user action | Same as audio recording. |
| Location tracking without user awareness | All location flows must be disclosed in Device Management. |
These features are technically buildable on Android. We choose not to build them. The reasoning is brand-centered (HardenedOS positions as privacy-respecting), not legal — most are legal in most jurisdictions if disclosed. We're stricter than the law because privacy-conscious customers trust us to be.
If a reseller demands these features, we redirect them to:
- Stock Android with an MDM platform that supports them.
- A self-hosted fork where they take on the trust burden themselves and it isn't called HardenedOS.
We do not negotiate the surveillance ceiling on a per-customer basis.
The universal policy capability set
Every key below is settable on any device's manifest. Resellers decide per-policy whether to enforce, leave at default, or omit. Omitted policies fall back to the platform default (listed below each key).
Lock screen + biometric
| Key | Type | Default | Notes |
|---|---|---|---|
screen_lock_required |
bool | true |
PIN/password required at boot and after timeout. |
screen_lock_max_attempts |
int | 10 |
Wipe after N failed unlocks. 0 = unlimited. |
require_biometric_after_seconds |
int | 60 |
Idle time before biometric needed for unlock. |
biometric_only |
bool | false |
Disallow PIN-only unlock once biometric is enrolled. |
disable_biometric_unlock |
bool | false |
Disable all biometric unlock (fingerprint + face +
iris; KEYGUARD_DISABLE_BIOMETRICS) so only the
PIN/password/pattern credential unlocks. Stronger than
biometric_only (which keeps fingerprint). Combine with a
strong screen_lock_required ("high") to force
a credential-only, strong-password device. |
Side-loading + developer tools
| Key | Type | Default | Notes |
|---|---|---|---|
allow_sideloading |
bool | false |
Per-source toggle for unknown-sources install. |
allow_developer_mode |
bool | false |
Settings → Developer options visibility. |
ota_channel |
enum | "stable" |
One of stable / beta /
dev. |
Fleet management
| Key | Type | Default | Notes |
|---|---|---|---|
allow_safe_mode |
bool | true |
Boot-to-safe-mode UI on long-press. |
allow_personal_accounts |
bool | true |
User can add Google / Apple / Microsoft accounts. |
allow_screenshots |
bool | true |
System screenshot key. |
disable_camera |
bool | false |
Camera APIs return error to all apps. |
disable_usb_transfer |
bool | false |
USB data lines blocked when locked. |
force_installed_apps |
string[] | [] |
Reseller-library packages preinstalled, user can't uninstall. |
blocked_apps |
string[] | [] |
Packages refused at install + uninstalled if present. |
disabled_system_apps |
string[] | [] |
Pre-installed apps hidden from the launcher (reversible,
setApplicationHidden). The policy editor exposes curated
"Disable built-in apps" toggles for the common
GrapheneOS/AOSP packages — Phone (com.android.dialer),
Messaging (com.android.messaging), App Store
(app.grapheneos.apps), Info
(app.grapheneos.info), Vanadium browser
(app.vanadium.browser) — which union into this list;
free-text entries are also accepted for anything else. ⚠️ Hiding
Phone/Messaging removes carrier calling/SMS (emergency dialer stays).
Hiding the Vanadium browser does not affect the
WebView. Protected packages (the Vanadium
WebView/Trichrome library, the HardenedOS DPC, SystemUI) are
rejected here and in blocked_apps — see
partner.ForbiddenHidePackages /
internal/partner/builtin_apps.go. |
always_on_vpn |
object | null |
{ "package": "...", "lockdown": bool } — no traffic
outside VPN if lockdown. |
allowed_wifi_networks |
string[] | [] |
SSID allowlist (WifiSsidPolicy, API
33+). When non-empty the device may connect only to
these SSIDs. Empty = no allowlist enforcement (does not
disable Wi-Fi). |
block_other_wifi |
bool | false |
DISALLOW_ADD_WIFI_CONFIG — user can't add
new Wi-Fi networks. Does not turn
Wi-Fi off or disconnect already-saved networks; it is not a radio
kill-switch. For a full Wi-Fi-off use disable_wifi. |
disable_wifi |
bool | false |
Effectively turns Wi-Fi off:
DISALLOW_CONFIG_WIFI + a match-nothing
WifiSsidPolicy allowlist, so the device can't associate
with any network or change Wi-Fi settings. Reversible;
overrides allowed_wifi_networks while set.
⚠️ The managed (non-privileged) tier has no Device-Owner API to power
the radio off in hardware (setWifiEnabled is a no-op for
non-system apps since Android 10); this is the effective equivalent. A
true radio-off needs the OS-fork tier. |
ota_install_window |
object | null |
{ "start_hour": 0-23, "end_hour": 0-23 } — AOSP
SystemUpdatePolicy window. No effect on
GrapheneOS (its Updater is independent). |
block_os_updates |
bool | unset | Freeze GrapheneOS OS updates by hiding the Updater app
(app.grapheneos.updater). true=freeze,
false=resume, unset=leave as-is. Reversible. ⚠️ A frozen
device stops receiving security updates — use for
staged rollouts / pinning, not permanently. This (not
ota_install_window) is the working OS-update control on
GrapheneOS. |
Connectivity radios (all reversible —
addUserRestriction when true,
clearUserRestriction when false/unset):
| Key | Type | Default | Notes |
|---|---|---|---|
disable_bluetooth |
bool | false |
Bluetooth off (DISALLOW_BLUETOOTH). |
allow_bluetooth_config |
bool | true |
When false, user can't change Bluetooth settings
(DISALLOW_CONFIG_BLUETOOTH); the radio itself is governed
by disable_bluetooth. |
disable_nfc |
bool | false |
Blocks NFC beam/share
(DISALLOW_OUTGOING_BEAM). Note: this is not a full
NFC radio-off — no Device-Owner API exists for that; this is the closest
DPM knob. |
disable_tethering |
bool | false |
Blocks Wi-Fi hotspot + USB tethering
+ Bluetooth tethering
(DISALLOW_CONFIG_TETHERING). |
disable_data_roaming |
bool | false |
Blocks cellular data while roaming
(DISALLOW_DATA_ROAMING). |
Look & feel
Branding overrides, settable per policy target — a
reseller can set a different wallpaper per device, per user-label, or
fleet-wide, using the normal policy precedence (device > user-label
> tier > reseller-wide). This is distinct from the reseller-wide
branding bundle
(PUT /partner/v1/branding), which is one active bundle per
reseller; these keys are targetable like any other policy.
| Key | Type | Default | Notes |
|---|---|---|---|
wallpaper_url |
string | null |
HTTPS URL the DPC fetches (≤5 MB) and applies to home + lock via
WallpaperManager. Empty/unset = leave the current
wallpaper. Cross-platform (any Android Enterprise device). |
wallpaper_sha256 |
string | null |
Required when wallpaper_url is set.
SHA-256 of the image bytes — the wallpaper host is not in the DPC TLS
pin-set, so the DPC refuses a wallpaper with no hash and re-applies only
when the hash changes. |
Per-device wallpaper: target a
wallpaper_url(+wallpaper_sha256) policy at a singletarget_device_idto give one device its own wallpaper without touching the reseller branding bundle. This is the per-customer/per-device wallpaper channel.
High-restriction policies
Pre-v0.2 these were government-tier-only. Now any reseller can enable them; they default off.
| Key | Type | Default | Notes |
|---|---|---|---|
kiosk_mode_enabled |
bool | false |
Lock device to a single allowed app or small set. |
kiosk_apps |
string[] | [] |
Packages allowed when kiosk_mode_enabled is true. |
geofence |
object | null |
Radius-based; device functionality varies inside/outside. |
remote_attestation_required |
bool | false |
Auditor-style: every sensitive app verifies device integrity before running. |
audit_log_enabled |
bool | false |
Persistent audit log streamed to reseller webhook with replay protection. |
tamper_response |
enum | "warn" |
One of warn / lock / wipe on
tamper detection. |
hardware_locked_settings |
string[] | [] |
Settings paths that cannot be changed even by user (e.g., USB debugging permanently off). |
Policy precedence — which policy wins a conflict
A single device usually matches several policies at once: the global
templates the platform ships, plus the reseller's own, each optionally
narrowed by target_tier, target_user_label,
and/or target_device_id. The manifest composer
(api/internal/device/service.go GetManifest)
merges them per key, last-write-wins, after ordering them by a
precedence rank (api/internal/partner/repository.go
policyMergeRank).
Two axes decide the rank, most significant first:
- Scope. A reseller-owned policy
(
reseller_idset) always overrides a global template (reseller_idNULL). Templates are a starting point; the reseller's own choices win. - Targeting specificity. Within a scope, the more narrowly a policy is targeted, the higher it ranks: device-targeted > user-label > tier-only / untargeted. A policy aimed at one device beats one aimed at a user (who may hold several devices), which beats a fleet-wide policy.
So for a given device the effective value of a key is taken from the most specific reseller-owned policy that sets it, falling back through less specific policies and finally the platform default.
The surveillance-ceiling block is reasserted after this merge (see below), so no combination of policies can ever flip a forbidden key on.
Tier field on existing rows
Every activation_codes, devices, and
policies row carries a tier (or
target_tier) string. With v0.2 this field is informational
only — it does not affect what policies are allowed, what defaults
apply, or how a manifest is composed.
- New rows write
"standard". - Existing rows keep their pre-v0.2 values (
personal/corporate/government). - All four values are accepted at the schema CHECK constraint level
(see migration
011_tier_collapse). - All four resolve to the same universal capability set in
manifest.AllowedFor().
A future migration may rewrite legacy values to standard
and drop the column entirely; for now, leaving it in place avoids
destructive change on any deployment.
DPC + server alignment
The Go-side authoritative implementation lives in
api/internal/manifest/tier.go. The Kotlin side
(apps/dpc/.../policy/) must keep its capability list in sync with
allPolicyKeys there. Mismatches indicate either an
intentional schema evolution (which requires an update to this document
first) or a bug.
The _forbidden_must_be_false block in every emitted
manifest is the wire-level reassertion of the surveillance ceiling:
every forbidden key from the table above is set explicitly to
false, and the kernel + DPC reject any manifest that
violates the assertion.