Skip to content

Repository files navigation

Self-Hosted Commitment System

🚧 Status: ALPHA — personal infrastructure, incomplete, and unsupported. Use at your own risk. Several modules are declared-but-not-implemented, behavior varies by device/OS, and the Android agent can lock or (in the worst case) require a factory-reset of a device — read docs/SAFETY.md first. Expect to read code, not just run it.

A personal, multi-device commitment device. A central control plane (self-hosted, reachable only over Tailscale) holds the desired-state policy for every enrolled device. Each device runs an agent that pulls policy, reconciles local state to match, enforces locally, and reports back.

The commitment property comes from one asymmetry: you cannot weaken the rules from the device you're trying to restrict. You must go to the plane, and weakening there is delayed (default 24h). Strengthening is instant.

This is personal infra, not a product. It is not parental control or commercial MDM. Honesty about how strong each tier actually is (below) is a feature.

⚠️ Before you install the Android agent — read docs/SAFETY.md

The Android agent provisions itself as a Device Owner and uses Lock Task (kiosk) mode. It is designed to be hard to remove. On a release build there is no in-app escape hatch — if you get locked and can't reach it over adb, your only way out is a factory reset that erases the whole device. This is not a normal app. Practice on a spare device or emulator with a debug build first, keep adb working, and back up before you provision. Only use it on a device you own. Used carelessly it can brick your daily phone. See docs/SAFETY.md for the full danger + recovery guide.


See it work

Operator creates a "Block YouTube" rule on the plane; the phone pulls it and the Device Owner hard-blocks the app — the phone can't lift it, only the plane can (after the cooldown).

Plane: block YouTube rule Phone: YouTube blocked by work policy

Full walkthrough (pair a device → confirm → create a rule → enforce): docs/DEMO.md. Reproduce it yourself on an emulator (no real phone, no Tailscale): docs/LOCAL-TESTING.md.


Layout

Path What Status
schema/ Shared policy schema + the strength partial order that drives the asymmetric delay, plus Ed25519 signed-policy primitives. Canonical Python models + generated policy.schema.json. ✅ built, 57 tests
plane/ FastAPI + Postgres control plane: pairing, per-device policy versioning (anti-rollback), change-intent queue (asymmetric delay), operator console behind auth + CSRF, signed-policy delivery, signals/health read side, Activity logbook, places, food. Editorial Calm web UI. Docker (plane + db + Radicale CalDAV). ✅ built, 141 tests, deployed
agent-linux/ Soft-tier Python + systemd agent: pull/reconcile/report, /etc/hosts blocks, managed-process termination, fail-secure cache, plain-language status command. ✅ built, 21 tests, live-demoed
agent-android/ Device Owner enforcement app: Lock Task enforcement, fail-secure enforcement engine, signed-policy verification, boot-resume; the stateful condition engine (lock-until-HR-goal), durable Room store, adaptive goal layer, watch signals, places/dwell, vitals UI, and on-device Gemma food journaling. (Unstoppable alarm, call screening: planned.) ✅ built, 190 JVM + 9 instrumented tests, installed & enforcing

All four share one policy schema (commitment-schema), so a rule defined once is understood by the plane and every agent. See docs/IMPLEMENTED.md for a full technical map of what's built, PORTING.md for the self-hosting guide, and VISION.md for where this is heading.

Design principles (non-negotiable)

  1. Fail-secure, never fail-trapped. Lost connectivity → keep enforcing the last cached policy. But every release condition is locally evaluable (NFC, geofence, elapsed time, steps) so a dead tailnet can never trap you either. Only remote_approval needs the plane, and its absence degrades gracefully.
  2. Asymmetric change delay. Strengthening applies immediately; weakening waits a cooldown. Always-stricter-now, relaxing-always-waits. (Anything ambiguous/mixed is conservatively treated as a weakening — see schema/commitment_schema/strength.py.)
  3. Phone is a read-only endpoint. No rule-editing on the phone. All rule CRUD lives in the plane.
  4. Honest strength per platform. Android + Device Owner is a genuine wall. A Linux box you have root on is an advisory speed bump — the agent lock is undoable by definition. We say so in code and UI and don't pretend otherwise.

Quick start (control plane)

On the host that will run the plane (it needs Docker + Tailscale):

cd plane
cp .env.example .env
# Edit .env: set POSTGRES_PASSWORD and TAILSCALE_IP (run `tailscale ip -4`).
docker compose up -d --build

The plane binds only to your Tailscale IP on port 8080 — no public listener, and Postgres is never published at all. Open http://<tailscale-ip>:8080/ (or the MagicDNS name) from a tailnet device to reach the web UI.

The web UI is built for a non-technical operator: the dashboard shows each device's live 🔒 Locked / 🔓 Open state, and each device opens a guided rule builder — plain-language "when does it lock / how does it unlock / what's still allowed", day and time pickers, app chips, and a live English summary of every rule. No JSON required (a raw-JSON editor is tucked under "Advanced"). Strengthening a rule applies instantly; loosening or deleting one is queued and shown under "Pending changes" until the 24h cooldown elapses.

Tailscale hardening

  • tailnet lock: tailscale lock init and sign nodes, so a stolen auth key can't add a rogue device.
  • ACLs: see plane/tailscale-acl.example.json — only tag:agent and tag:admin may reach tag:plane:8080; nothing is public.
  • MagicDNS: address the plane by name (e.g. plane.your-tailnet.ts.net) instead of a raw IP.

FCM (optional but recommended)

The plane sends a data-only "sync now" nudge; the agent then pulls over Tailscale. No secrets or policy ever ride in the push payload.

  1. Create a Firebase project, download a service-account JSON.
  2. Mount it into the container and set FCM_CREDENTIALS_PATH in .env (uncomment the volumes: line in docker-compose.yml).
  3. Without it, the plane uses a log-only pusher; agents still pull on a timer and on boot, so convergence is just slower, never broken.

Pairing flow

The unauthenticated /enroll endpoint is gone (audit #1). Bootstrapping a device now goes through a confirmed pairing flow — there is no agent token until the operator approves:

  1. Bring the device onto the tailnet (install Tailscale, join, get ACL'd in).
  2. The operator generates a one-time pairing code in the console.
  3. The agent calls POST /pair/claim with the code (+ its name/platform/capabilities) and gets a flow_id + per-claim claim_token; it then polls POST /pair/poll.
  4. The operator confirms the pairing in the console; the next poll returns the device_id + agent_token. From there the agent pulls policy and reconciles.

Adding a device later is the same flow — the system is built to grow.

Sync protocol (summary)

Call Direction Purpose
POST /pair/claim + POST /pair/poll agent → plane confirmed pairing → device_id + agent_token
GET /policy?device=&since= agent → plane pull {version, content_hash, body} (signed when enabled)
POST /state agent → plane report sessions/alarms/journal/health/signals
POST /devices/{id}/intents device/UI → plane propose an edit (classified + delayed)
data-only FCM plane → agent "sync now" wake

The reconcile loop is idempotent: applying the same version twice is a no-op. Device tokens are scoped to their own policy, and policy versions are monotonic (anti-rollback).


Linux agent (soft tier)

sudo cp -r agent-linux /opt/commitment-agent && cd /opt/commitment-agent
sudo pip install ./ ../schema      # or use a venv
sudo mkdir -p /etc/commitment-agent
sudo cp config.example.json /etc/commitment-agent/config.json   # then edit
sudo commitment-agent --config /etc/commitment-agent/config.json enroll --name "$(hostname)"
sudo cp systemd/commitment-agent.service /etc/systemd/system/
sudo systemctl enable --now commitment-agent

The shared policy decides when this box is locked (schedule / manual). The local config.json decides what this advisory tier does about it: which managed_processes it will terminate and which block_domains it null-routes via /etc/hosts.

Check it in plain English anytime — no JSON, no daemon required:

commitment-agent --config /etc/commitment-agent/config.json status
# 🔒  Locked right now  (advisory — this tier is a speed bump, not a wall)
#    "Deep work" is active — unlocks after 1 hour
# ... or ...
# 🔓  Open  —  no rule is locking this machine right now.

Honest caveat: with root you can stop the service or undo the hosts edit. This is digital-sabbath friction, not a wall. We deliberately don't over-engineer tamper resistance on a box you own root on.


Android agent (hard tier) — provisioning

⚠️ Read docs/SAFETY.md first. This step makes the phone hard to get out of. On a release build, recovery from an active lock may require a factory reset. Rehearse teardown on a spare device with a debug build before doing this for real.

The phone is the genuine wall, via Device Owner (one-time, over ADB):

# Cleanest right after a factory reset. Device Owner refuses to set if any
# account is present, so remove ALL accounts first.
adb install agent-android/app/build/outputs/apk/release/app-release.apk
adb shell dpm set-device-owner com.commitment.agent/.admin.LockAdminReceiver
# expect: Success

Device Owner is what unlocks unrestricted Lock Task Mode, uninstall protection, and the user-restriction levers (DISALLOW_ADJUST_VOLUME, DISALLOW_SAFE_BOOT) the unstoppable alarm needs. Without it the app degrades to a weak Accessibility blocker — not the target.

Teardown: adb shell dpm remove-active-admin com.commitment.agent/.admin.LockAdminReceiver (only with no active session) or factory reset. Factory reset is the ultimate escape and is documented as such. Full recovery ladder + the release-build "no escape hatch" caveat: docs/SAFETY.md.


The residual weak link (read this)

The plane is still you. The asymmetric delay on weakening edits is the primary mitigation — it removes easy in-the-moment temptation. For something closer to truly binding, gate weakening behind a second authenticated device or a trusted person. We state this honestly: it's the difference between "removes easy temptation" and "genuinely can't get out."


Development

python3 -m venv .venv && . .venv/bin/activate
pip install -e ./schema -e "./plane[dev]" -e "./agent-linux[dev]"
pytest schema plane agent-linux        # 219 tests (+ ~199 Android tests via Gradle)
python schema/export_schema.py > schema/policy.schema.json   # regenerate JSON Schema

License

GNU AGPL-3.0. If you run a modified version as a network service, you must make your source available to its users. This is personal infrastructure shared as-is, with no warranty — see docs/SAFETY.md before running the Android agent.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages