HardenedOS

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:

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:

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:

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 single target_device_id to 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:

  1. Scope. A reseller-owned policy (reseller_id set) always overrides a global template (reseller_id NULL). Templates are a starting point; the reseller's own choices win.
  2. 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.

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.