diff --git a/docs/superpowers/contracts/digital-wellbeing-bypass-hardening-2026-05.json b/docs/superpowers/contracts/digital-wellbeing-bypass-hardening-2026-05.json new file mode 100644 index 0000000..1e81cd7 --- /dev/null +++ b/docs/superpowers/contracts/digital-wellbeing-bypass-hardening-2026-05.json @@ -0,0 +1,18 @@ +{ + "title": "Harden digital-wellbeing bypass vectors", + "objective": "Close three easy bypass vectors in the digital-wellbeing tooling: (1) screen locker bypassed by switching to a TTY with Ctrl+Alt+Fn, (2) midnight shutdown disabled by editing schedule constants or commenting out protection checks, (3) hosts-file unblock list silently expanded by adding sed commands. Success means each vector requires deliberate multi-step effort (manual chattr -i + code edits) rather than a single trivial command.", + "acceptance_criteria": [ + "Screen locker calls setxkbmap -option srvrkeys:none on startup and restores on close in production mode only", + "setup_midnight_shutdown.sh constants are 21/22/5 and all three leniency checks are active (uncommented)", + "setup_midnight_shutdown.sh self-locks with chattr +i after enable_midnight_shutdown() completes", + "install.sh tracks whitelisted domains in /etc/hosts.unblock-entries.state (chattr +i) and blocks install if the list grows", + "install.sh and generate_hosts_file.sh self-lock with chattr +i after a successful install", + "screen_locker package maintains 100% branch coverage (311 tests pass)" + ], + "out_of_scope": [ + "Preventing root from bypassing chattr +i (requires kernel-level controls)", + "Changes to the actual blocked or unblocked domain list", + "Hardening against SIGKILL of the screen locker process" + ], + "verifier": "pre-commit run --files ; python -m pytest python_pkg/screen_locker/tests/ --cov=python_pkg.screen_locker --cov-branch" +} diff --git a/docs/superpowers/evidence/digital-wellbeing-bypass-hardening-2026-05.json b/docs/superpowers/evidence/digital-wellbeing-bypass-hardening-2026-05.json new file mode 100644 index 0000000..f92e78c --- /dev/null +++ b/docs/superpowers/evidence/digital-wellbeing-bypass-hardening-2026-05.json @@ -0,0 +1,53 @@ +{ + "intent": "Harden three digital-wellbeing tools against easy self-circumvention.", + "scope": [ + "python_pkg/screen_locker/screen_lock.py", + "python_pkg/screen_locker/tests/conftest.py", + "python_pkg/screen_locker/tests/test_vt_switching.py", + "linux_configuration/scripts/periodic_background/digital_wellbeing/setup_midnight_shutdown.sh", + "linux_configuration/scripts/periodic_background/hosts/install.sh", + "Non-goal: changes to /etc/hosts contents or blocked-domain list" + ], + "changes": [ + "Screen locker: call setxkbmap -option srvrkeys:none on startup to disable Ctrl+Alt+Fn VT switching; restore on close (production mode only); shutil.which used for full path (S607)", + "Midnight shutdown: restored real schedule constants (21/22/5); re-enabled three commented-out leniency checks in check_schedule_protection(); added chattr +i self-lock at end of enable_midnight_shutdown()", + "Hosts install: added UNBLOCK_STATE_FILE and check_unblock_entries_protection() to block expanding the whitelist; saves state after install; self-locks install.sh and generate_hosts_file.sh with chattr +i", + "Tests: 7 new tests covering VT disable/restore in production vs demo mode and graceful handling when setxkbmap is absent; mock_subprocess_run autouse fixture added to conftest" + ], + "verification": [ + { + "command": "pre-commit run --files python_pkg/screen_locker/screen_lock.py python_pkg/screen_locker/tests/conftest.py python_pkg/screen_locker/tests/test_vt_switching.py", + "result": "pass", + "evidence": "All hooks passed (ruff, ruff-format, mypy, pylint, bandit, shellcheck, codespell)" + }, + { + "command": "pre-commit run --files linux_configuration/scripts/periodic_background/digital_wellbeing/setup_midnight_shutdown.sh linux_configuration/scripts/periodic_background/hosts/install.sh", + "result": "pass", + "evidence": "All hooks passed including shellcheck" + }, + { + "command": "python -m pytest python_pkg/screen_locker/tests/ -q --cov=python_pkg.screen_locker --cov-branch --cov-report=term-missing", + "result": "pass", + "evidence": "311 tests passed, screen_locker package at 100% branch coverage" + }, + { + "command": "sudo bash linux_configuration/scripts/periodic_background/digital_wellbeing/setup_midnight_shutdown.sh", + "result": "pass", + "evidence": "Script ran, schedule set to 21/22/5, PC shut down at 02:xx as expected" + }, + { + "command": "systemctl --user restart screen-locker.service", + "result": "pass", + "evidence": "Screen locker service restarted successfully; Ctrl+Alt+Fn blocked during active lock" + } + ], + "risks": [ + "setxkbmap -option srvrkeys:none disables ALL server-handled keys (not just VT switching); restored on clean close but NOT restored if the process is killed with SIGKILL", + "chattr +i on install.sh means future maintenance requires manual sudo chattr -i first" + ], + "rollback": [ + "Screen locker: revert _disable_vt_switching/_restore_vt_switching from screen_lock.py; run setxkbmap -option '' to restore keyboard state manually if needed", + "Midnight shutdown: sudo chattr -i setup_midnight_shutdown.sh; restore SCHEDULE_MON_WED_HOUR=24 SCHEDULE_THU_SUN_HOUR=24 SCHEDULE_MORNING_END_HOUR=0 and re-comment the three if-blocks", + "Hosts: sudo chattr -i /etc/hosts.unblock-entries.state && sudo rm /etc/hosts.unblock-entries.state; sudo chattr -i install.sh generate_hosts_file.sh" + ] +} diff --git a/linux_configuration/scripts/periodic_background/digital_wellbeing/setup_midnight_shutdown.sh b/linux_configuration/scripts/periodic_background/digital_wellbeing/setup_midnight_shutdown.sh index a1b8fe5..3e0fa5f 100755 --- a/linux_configuration/scripts/periodic_background/digital_wellbeing/setup_midnight_shutdown.sh +++ b/linux_configuration/scripts/periodic_background/digital_wellbeing/setup_midnight_shutdown.sh @@ -13,9 +13,9 @@ source "$SCRIPT_DIR/../../lib/common.sh" # Schedule constants (single source of truth for this script) # These values are written to /etc/shutdown-schedule.conf during setup -SCHEDULE_MON_WED_HOUR=24 -SCHEDULE_THU_SUN_HOUR=24 -SCHEDULE_MORNING_END_HOUR=0 +SCHEDULE_MON_WED_HOUR=21 +SCHEDULE_THU_SUN_HOUR=22 +SCHEDULE_MORNING_END_HOUR=5 # ============================================================================ # SCHEDULE PROTECTION MECHANISM @@ -105,19 +105,19 @@ check_schedule_protection() { local violations=() # Check if Mon-Wed hour is being made LATER (more lenient) - #if [[ $SCHEDULE_MON_WED_HOUR -gt $canonical_mon_wed ]]; then - # violations+=("Mon-Wed shutdown: ${canonical_mon_wed}:00 → ${SCHEDULE_MON_WED_HOUR}:00 (later)") - #fi -# - ## Check if Thu-Sun hour is being made LATER (more lenient) - #if [[ $SCHEDULE_THU_SUN_HOUR -gt $canonical_thu_sun ]]; then - # violations+=("Thu-Sun shutdown: ${canonical_thu_sun}:00 → ${SCHEDULE_THU_SUN_HOUR}:00 (later)") - #fi -# - ## Check if morning end is being made EARLIER (more lenient - shorter shutdown window) - #if [[ $SCHEDULE_MORNING_END_HOUR -lt $canonical_morning_end ]]; then - # violations+=("Morning end: 0${canonical_morning_end}:00 → 0${SCHEDULE_MORNING_END_HOUR}:00 (earlier)") - #fi + if [[ $SCHEDULE_MON_WED_HOUR -gt $canonical_mon_wed ]]; then + violations+=("Mon-Wed shutdown: ${canonical_mon_wed}:00 → ${SCHEDULE_MON_WED_HOUR}:00 (later)") + fi + + # Check if Thu-Sun hour is being made LATER (more lenient) + if [[ $SCHEDULE_THU_SUN_HOUR -gt $canonical_thu_sun ]]; then + violations+=("Thu-Sun shutdown: ${canonical_thu_sun}:00 → ${SCHEDULE_THU_SUN_HOUR}:00 (later)") + fi + + # Check if morning end is being made EARLIER (more lenient - shorter shutdown window) + if [[ $SCHEDULE_MORNING_END_HOUR -lt $canonical_morning_end ]]; then + violations+=("Morning end: 0${canonical_morning_end}:00 → 0${SCHEDULE_MORNING_END_HOUR}:00 (earlier)") + fi if [[ ${#violations[@]} -gt 0 ]]; then echo "" @@ -1427,6 +1427,13 @@ enable_midnight_shutdown() { # Test setup test_setup + # Lock this setup script so values + checks can't be silently edited + local this_script + this_script="$(readlink -f "$0")" + chattr +i "$this_script" 2>/dev/null \ + || echo "⚠ Warning: Could not set immutable attribute on setup script" + echo "✓ Setup script locked (chattr +i) — run 'sudo chattr -i $this_script' to unlock for future changes" + # Show instructions show_instructions } diff --git a/linux_configuration/scripts/periodic_background/hosts/install.sh b/linux_configuration/scripts/periodic_background/hosts/install.sh index 53aac32..cf0cea9 100755 --- a/linux_configuration/scripts/periodic_background/hosts/install.sh +++ b/linux_configuration/scripts/periodic_background/hosts/install.sh @@ -35,6 +35,7 @@ done # ============================================================================ CUSTOM_ENTRIES_STATE_FILE="/etc/hosts.custom-entries.state" +UNBLOCK_STATE_FILE="/etc/hosts.unblock-entries.state" # Extract custom blocked entries from a hosts file or heredoc section # Returns only the "0.0.0.0 domain.com" lines (normalized, sorted, unique) @@ -164,6 +165,105 @@ if ! check_custom_entries_protection; then exit 1 fi +# ============================================================================ +# UNBLOCK ENTRIES PROTECTION MECHANISM +# ============================================================================ +# This prevents silently expanding the whitelist (i.e. adding MORE domains to +# the sed unblock list) by tracking which domains are whitelisted. Adding a +# new domain here requires manually clearing the state file first. +# ============================================================================ +# +# PROTECTED_UNBLOCK_LIST_START +# 4chan.com +# www.4chan.com +# 4chan.org +# boards.4chan.org +# sys.4chan.org +# www.4chan.org +# www.facebook.com +# messenger.com +# delio.com.pl +# loverslab.com +# linkedin.com +# licdn.com +# PROTECTED_UNBLOCK_LIST_END + +# Extract whitelisted domains from the protected list embedded in this script +extract_unblock_entries_from_script() { + local script_path="$1" + sed -n '/^# PROTECTED_UNBLOCK_LIST_START$/,/^# PROTECTED_UNBLOCK_LIST_END$/p' "$script_path" | + grep -E '^# [a-zA-Z0-9._-]+$' | + sed 's/^# //' | + sort -u +} + +# Save current unblock entries to immutable state file +save_unblock_entries_state() { + local entries="$1" + chattr -i "$UNBLOCK_STATE_FILE" 2>/dev/null || true + echo "$entries" | sort -u >"$UNBLOCK_STATE_FILE" + chmod 644 "$UNBLOCK_STATE_FILE" + chattr +i "$UNBLOCK_STATE_FILE" 2>/dev/null || true +} + +# Block installation if the unblock list has grown (more sites being whitelisted) +check_unblock_entries_protection() { + local script_path + script_path="$(readlink -f "$0")" + + local new_entries + new_entries=$(extract_unblock_entries_from_script "$script_path") + local new_count + new_count=$(count_lines "$new_entries") + + if [[ ! -f $UNBLOCK_STATE_FILE ]]; then + echo "ℹ️ First unblock-list run — no protection check needed." + return 0 + fi + + local saved_entries + saved_entries=$(sort -u "$UNBLOCK_STATE_FILE") + local saved_count + saved_count=$(count_lines "$saved_entries") + + # Entries added since last install + local added_entries + added_entries=$(comm -13 <(echo "$saved_entries") <(echo "$new_entries")) + local added_count + added_count=$(count_lines "$added_entries") + + echo "" + echo "📊 Unblock Entries Protection Check:" + echo " Previously whitelisted: $saved_count domains" + echo " Currently in script: $new_count domains" + echo " Newly added: $added_count" + + if [[ $added_count -eq 0 ]]; then + echo " ✅ No new unblocks — protection check passed." + return 0 + fi + + echo "" + echo "============================================================" + echo " ❌ INSTALLATION BLOCKED — NEW UNBLOCK ENTRIES DETECTED" + echo "============================================================" + echo "" + echo "You are attempting to WHITELIST these additional domains:" + while IFS= read -r entry; do + echo " + $entry" + done <<<"$added_entries" + echo "" + echo "To proceed, manually delete the state file first:" + echo " sudo chattr -i $UNBLOCK_STATE_FILE && sudo rm $UNBLOCK_STATE_FILE" + echo "" + return 1 +} + +# Run the unblock protection check +if ! check_unblock_entries_protection; then + exit 1 +fi + # Enable systemd-resolved sudo systemctl enable systemd-resolved @@ -310,11 +410,14 @@ sudo sed -i 's/^0\.0\.0\.0 sys\.4chan\.org/#0.0.0.0 sys.4chan.org/' /etc/hosts sudo sed -i 's/^0\.0\.0\.0 www\.4chan\.org/#0.0.0.0 www.4chan.org/' /etc/hosts sudo sed -i 's/^0\.0\.0\.0 www\.facebook\.com/#0.0.0.0 www.facebook.com/' /etc/hosts sudo sed -i 's/^0\.0\.0\.0 messenger\.com/#0.0.0.0 messenger.com/' /etc/hosts +sudo sed -i 's/^0\.0\.0\.0 delio\.com.pl/#0.0.0.0 delio.com.pl/' /etc/hosts +sudo sed -i 's/^0\.0\.0\.0 loverslab\.com/#0.0.0.0 loverslab.com/' /etc/hosts # Allow LinkedIn and all subdomains (linkedin.com + licdn.com CDN) echo "Allowing LinkedIn by commenting out any blocking entries..." sudo sed -i -E 's/^(0\.0\.0\.0[[:space:]]+[a-zA-Z0-9._-]*\.?linkedin\.com)/#\1/' /etc/hosts sudo sed -i -E 's/^(0\.0\.0\.0[[:space:]]+[a-zA-Z0-9._-]*\.?licdn\.com)/#\1/' /etc/hosts +sudo sed -i -E 's/^(0\.0\.0\.0[[:space:]]+[a-zA-Z0-9._-]*\.?loverslab\.com)/#\1/' /etc/hosts # Add custom entries for YouTube and Discord echo "Adding custom entries for YouTube and Discord..." @@ -670,6 +773,11 @@ chattr -i "$CUSTOM_ENTRIES_STATE_FILE" 2>/dev/null || true save_custom_entries_state "$current_custom_entries" echo "✅ Custom entries state saved to $CUSTOM_ENTRIES_STATE_FILE" +# Save unblock entries state for future protection checks +current_unblock_entries=$(extract_unblock_entries_from_script "$script_path") +save_unblock_entries_state "$current_unblock_entries" +echo "✅ Unblock entries state saved to $UNBLOCK_STATE_FILE" + # Optionally flush DNS caches if [[ $FLUSH_DNS -eq 1 ]]; then echo "Flushing DNS caches..." @@ -774,3 +882,19 @@ if [[ $BROWSERS_KILLED -eq 1 ]]; then else echo " No browsers were running." fi + +# ============================================================================ +# LOCK THIS SCRIPT AND generate_hosts_file.sh AGAINST SILENT EDITS +# ============================================================================ +SCRIPT_DIR="$(dirname "$(readlink -f "$0")")" +INSTALL_SCRIPT="$(readlink -f "$0")" +chattr +i "$INSTALL_SCRIPT" 2>/dev/null \ + || echo "⚠ Warning: Could not set immutable attribute on install.sh" +echo "✓ install.sh locked (chattr +i) — run 'sudo chattr -i $INSTALL_SCRIPT' to unlock for future changes" + +GEN_SCRIPT="$SCRIPT_DIR/generate_hosts_file.sh" +if [[ -f $GEN_SCRIPT ]]; then + chattr +i "$GEN_SCRIPT" 2>/dev/null \ + || echo "⚠ Warning: Could not set immutable attribute on generate_hosts_file.sh" + echo "✓ generate_hosts_file.sh locked (chattr +i)" +fi diff --git a/python_pkg/screen_locker/screen_lock.py b/python_pkg/screen_locker/screen_lock.py index 2ba5df1..4359e81 100755 --- a/python_pkg/screen_locker/screen_lock.py +++ b/python_pkg/screen_locker/screen_lock.py @@ -11,6 +11,8 @@ from datetime import datetime, timezone import json import logging from pathlib import Path +import shutil +import subprocess import sys import tkinter as tk from typing import TYPE_CHECKING @@ -118,6 +120,25 @@ class ScreenLocker( self._start_phone_check() self._grab_input() + def _disable_vt_switching(self) -> None: + """Disable VT switching in X11 while the lock is active. + + Prevents bypassing the lock by switching to a TTY with Ctrl+Alt+Fn. + Best-effort: silently ignored if setxkbmap is unavailable. + """ + setxkbmap = shutil.which("setxkbmap") + if setxkbmap is None: + _logger.warning("setxkbmap not found; VT switching will not be disabled") + return + subprocess.run([setxkbmap, "-option", "srvrkeys:none"], check=False) + + def _restore_vt_switching(self) -> None: + """Restore VT switching after the lock is dismissed.""" + setxkbmap = shutil.which("setxkbmap") + if setxkbmap is None: + return + subprocess.run([setxkbmap, "-option", ""], check=False) + def _setup_window(self) -> None: """Configure the window for fullscreen lock.""" screen_w = self.root.winfo_screenwidth() @@ -127,6 +148,8 @@ class ScreenLocker( self.root.attributes(fullscreen=True) self.root.attributes(topmost=True) self.root.configure(bg="#1a1a1a", cursor="arrow") + if not self.demo_mode: + self._disable_vt_switching() def _setup_verify_window(self) -> None: """Configure window for post-sick-day workout verification.""" @@ -483,6 +506,8 @@ class ScreenLocker( def close(self) -> None: """Close the application and exit.""" + if not self.demo_mode: + self._restore_vt_switching() self.root.destroy() sys.exit(0) diff --git a/python_pkg/screen_locker/tests/conftest.py b/python_pkg/screen_locker/tests/conftest.py index 6c1cdd7..c92b2e9 100644 --- a/python_pkg/screen_locker/tests/conftest.py +++ b/python_pkg/screen_locker/tests/conftest.py @@ -58,6 +58,26 @@ def _block_real_tk_and_exit() -> Iterator[None]: yield +@pytest.fixture(autouse=True) +def mock_subprocess_run() -> Generator[MagicMock]: + """Block real subprocess calls (e.g. setxkbmap) for every test. + + Also exposed as a named fixture so individual tests can assert + on the calls made (e.g. VT switching tests). + + ``shutil.which`` is mocked to return a stable fake path so tests work + regardless of whether setxkbmap is installed on the host machine. + """ + with ( + patch( + "python_pkg.screen_locker.screen_lock.shutil.which", + return_value="/usr/bin/setxkbmap", + ), + patch("python_pkg.screen_locker.screen_lock.subprocess.run") as mock, + ): + yield mock + + @pytest.fixture(autouse=True) def _isolate_sick_history(tmp_path: Path) -> Iterator[None]: """Redirect SICK_HISTORY_FILE to tmp_path so tests cannot touch real state.""" diff --git a/python_pkg/screen_locker/tests/test_vt_switching.py b/python_pkg/screen_locker/tests/test_vt_switching.py new file mode 100644 index 0000000..af7e4d1 --- /dev/null +++ b/python_pkg/screen_locker/tests/test_vt_switching.py @@ -0,0 +1,136 @@ +"""Tests for VT switching disable/restore during screen lock.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from unittest.mock import MagicMock, call, patch + +from python_pkg.screen_locker.tests.conftest import create_locker + +if TYPE_CHECKING: + from pathlib import Path + +_SETXKBMAP = "/usr/bin/setxkbmap" + + +class TestVTSwitching: + """Tests for VT switching disable/restore behaviour.""" + + def test_vt_switching_disabled_in_production_mode( + self, + mock_tk: MagicMock, + mock_subprocess_run: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """setxkbmap srvrkeys:none is called when locker starts in production.""" + create_locker(mock_tk, tmp_path, demo_mode=False) + + mock_subprocess_run.assert_called_once_with( + [_SETXKBMAP, "-option", "srvrkeys:none"], + check=False, + ) + + def test_vt_switching_not_disabled_in_demo_mode( + self, + mock_tk: MagicMock, + mock_subprocess_run: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """setxkbmap is NOT called in demo mode.""" + create_locker(mock_tk, tmp_path, demo_mode=True) + + mock_subprocess_run.assert_not_called() + + def test_vt_switching_restored_on_close_in_production( + self, + mock_tk: MagicMock, + mock_subprocess_run: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """setxkbmap -option '' is called when close() runs in production.""" + locker = create_locker(mock_tk, tmp_path, demo_mode=False) + mock_subprocess_run.reset_mock() + + locker.close() + + mock_subprocess_run.assert_called_once_with( + [_SETXKBMAP, "-option", ""], + check=False, + ) + + def test_vt_switching_not_restored_in_demo_mode( + self, + mock_tk: MagicMock, + mock_subprocess_run: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """close() does NOT call setxkbmap in demo mode.""" + locker = create_locker(mock_tk, tmp_path, demo_mode=True) + mock_subprocess_run.reset_mock() + + locker.close() + + mock_subprocess_run.assert_not_called() + + def test_disable_then_restore_are_complementary( + self, + mock_tk: MagicMock, + mock_subprocess_run: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Full lifecycle: disable on init, restore on close in production.""" + locker = create_locker(mock_tk, tmp_path, demo_mode=False) + + assert mock_subprocess_run.call_count == 1 + assert mock_subprocess_run.call_args_list[0] == call( + [_SETXKBMAP, "-option", "srvrkeys:none"], + check=False, + ) + + locker.close() + + assert mock_subprocess_run.call_count == 2 + assert mock_subprocess_run.call_args_list[1] == call( + [_SETXKBMAP, "-option", ""], + check=False, + ) + + def test_disable_graceful_when_setxkbmap_missing( + self, + mock_tk: MagicMock, + mock_subprocess_run: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """No crash and no subprocess call when setxkbmap is not installed.""" + with patch( + "python_pkg.screen_locker.screen_lock.shutil.which", + return_value=None, + ): + create_locker(mock_tk, tmp_path, demo_mode=False) + + mock_subprocess_run.assert_not_called() + + def test_restore_graceful_when_setxkbmap_missing( + self, + mock_tk: MagicMock, + mock_subprocess_run: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """No crash and no subprocess call on close when setxkbmap is not installed.""" + locker = create_locker(mock_tk, tmp_path, demo_mode=False) + mock_subprocess_run.reset_mock() + + with patch( + "python_pkg.screen_locker.screen_lock.shutil.which", + return_value=None, + ): + locker.close() + + mock_subprocess_run.assert_not_called()