diff --git a/docs/superpowers/contracts/session-autopsy-mutations-split-2026-08-16.json b/docs/superpowers/contracts/session-autopsy-mutations-split-2026-08-16.json new file mode 100644 index 00000000..a51c440b --- /dev/null +++ b/docs/superpowers/contracts/session-autopsy-mutations-split-2026-08-16.json @@ -0,0 +1,24 @@ +{ + "title": "Split mutate_session_autopsy_hooks.py and test_security_hardening.sh", + "objective": "Bring two files under the repo-wide 250-line cap: meta/scripts/mutate_session_autopsy_hooks.py (253, over by 3) by moving its eight mutation operators into a sibling module, and linux_configuration/tests/test_security_hardening.sh (423) by moving its five test sections into three sourced libs -- neither changing what any check asserts or reports.", + "acceptance_criteria": [ + "Both mutate_session_autopsy_hooks.py and _session_autopsy_mutations.py are at most 250 lines.", + "ruff, mypy and pylint all pass on both files with no suppression added and no per-file-ignore introduced.", + "Running the script produces output byte-identical to the same run at HEAD: the same eight mutations in the same order, each killing the same named tests, ending in 'PASS: all 12 tests killed by at least one mutation'.", + "The deployed hooks under ~/.claude/hooks are not modified, which the script guarantees by copying before mutating.", + "No test file is edited: nothing imports or patches this module, so the split needs no test change at all.", + "test_security_hardening.sh and all three new libs are at most 250 lines, and the entry keeps its filename because .github/workflows/shell-tests.yml runs the suite by filename.", + "The suite's output is byte-identical to a run from a detached worktree at HEAD once the repo path prefix is normalised, with the same exit status and the same Passed 18 / Failed 1 / Skipped 5 tally on this host.", + "Each new shell lib passes shellcheck ALONE, without -x, which is the check that decides whether the seam is real; the SC2034 the seam exposes is resolved with a live_section() setter rather than a disable comment.", + "shfmt reports no diff on the entry or any new lib." + ], + "out_of_scope": [ + "Changing any mutation operator, the set of mutations, or the tests they target.", + "Adding meta/scripts/__init__.py. The sibling _schema_validation.py already establishes the convention here: a #!/usr/bin/env python3 shebang marks the file as a script and satisfies ruff's INP001 without turning meta/scripts into a package.", + "Changing how the pre-commit hook invokes the script.", + "Fixing the one pre-existing failure test_security_hardening.sh reports on this host.", + "Splitting block_compulsive_opening.sh. Its install_all copies the running script to /usr/local/bin, so an entry+lib shape would ship an entry whose SCRIPT_DIR resolves to a directory with no lib/, breaking three daily-use apps and the pacman rewrap hook.", + "Wiring the file-length pre-commit gate, which must land only once --all already exits 0." + ], + "verifier": "python3 meta/scripts/mutate_session_autopsy_hooks.py diffed against the same command run from a detached worktree at HEAD (expects identical output), plus pre-commit run --files on both files covering ruff, mypy, pylint and bandit; and bash linux_configuration/tests/test_security_hardening.sh diffed against the same suite run from a detached worktree at HEAD, plus shellcheck on each lib alone, shellcheck -x on the entry, and shfmt -d on all four shell files." +} diff --git a/docs/superpowers/evidence/session-autopsy-mutations-split-2026-08-16.json b/docs/superpowers/evidence/session-autopsy-mutations-split-2026-08-16.json new file mode 100644 index 00000000..244d2261 --- /dev/null +++ b/docs/superpowers/evidence/session-autopsy-mutations-split-2026-08-16.json @@ -0,0 +1,45 @@ +{ + "intent": "Bring mutate_session_autopsy_hooks.py under the 250-line cap by extracting its mutation operators, leaving the mutation results unchanged.", + "scope": [ + "meta/scripts/mutate_session_autopsy_hooks.py (253 -> 178 lines)", + "New: meta/scripts/_session_autopsy_mutations.py (100 lines)", + "Non-goal: changing any mutation, or the hooks and tests they exercise" + ], + "changes": [ + "Moved the eight mutation operators (_gut, _drop_transcript_guard, _widen_count, _drop_numeric_guard, _drop_readable_guard, _always_announce, _unguarded_and_always_announce), the Mutation NamedTuple, the MUTATIONS table and the _GUTTED constant into _session_autopsy_mutations.py.", + "Promoted _END_HOOK/_START_HOOK to END_HOOK/START_HOOK and _MUTATIONS to MUTATIONS, since they now cross a module boundary, and declared __all__ for the four exported names.", + "The driver keeps _TEST_FILE, _OUTCOME, and everything that touches the filesystem or runs pytest. The new module is pure text transforms, which makes the 'deployed hooks are never modified' guarantee easier to see.", + "Gave the new module a #!/usr/bin/env python3 shebang, matching the sibling _schema_validation.py. That is what satisfies ruff's INP001 here -- ruff exempts files it recognises as scripts -- so no __init__.py and no per-file-ignore were needed." + ], + "verification": [ + { + "command": "python3 meta/scripts/mutate_session_autopsy_hooks.py, diffed against the same run from a detached worktree at HEAD", + "result": "pass", + "evidence": "Byte-identical output. All eight mutations run in the same order and kill the same named tests (gut-end 2, gut-start 1, end-no-transcript-guard 4, start-count-ge-zero 1, start-no-numeric-guard 1, start-no-readable-guard 0, start-always-announce 3, start-unguarded-announce 5), ending in 'PASS: all 12 tests killed by at least one mutation'." + }, + { + "command": "pre-commit run --files on both files", + "result": "pass", + "evidence": "ruff, mypy, pylint and bandit all pass. Three real defects were caught and fixed rather than suppressed: the new module needed its own `import re`; the driver still annotates _apply with Mutation and so needed that name imported too (F821 from ruff, name-defined from mypy, E0602 from pylint -- three tools, one bug); and INP001 was answered with the sibling's shebang convention." + }, + { + "command": "bash ~/utils/scripts/check_file_length.sh", + "result": "pass", + "evidence": "driver 178, mutations 100. Neither appears in the violation list." + }, + { + "command": "grep for importers and patch targets", + "result": "pass", + "evidence": "Nothing in the repository imports this module and no test patches it; it is only a pre-commit entry point (.pre-commit-config.yaml:276). So no test file needed editing, and the decision-8 bound on test edits does not come into play." + } + ], + "risks": [ + "The bare `from _session_autopsy_mutations import ...` relies on Python putting the script's own directory on sys.path. That holds when the file is run as a script, which is the only way it is used -- verified by actually running it from the repository root, the same way .pre-commit-config.yaml invokes it. The sibling validate_evidence.py imports _schema_validation the same way.", + "The first run after the split failed with NameError on `re`, caught only by executing the script -- ast.parse had reported the file as fine. A missing import in a moved block is invisible to a parse check.", + "A PostToolUse formatter fired on the new module immediately after the `import re` edit; the import was re-checked with grep before proceeding, per the known trap." + ], + "rollback": [ + "git revert the commit; the driver returns to its 253-line form and the sibling disappears.", + "After rollback, run python3 meta/scripts/mutate_session_autopsy_hooks.py and confirm it still ends in 'PASS: all 12 tests killed by at least one mutation'." + ] +} diff --git a/docs/superpowers/evidence/split-test-security-hardening-2026-08-16.json b/docs/superpowers/evidence/split-test-security-hardening-2026-08-16.json new file mode 100644 index 00000000..a66f49c1 --- /dev/null +++ b/docs/superpowers/evidence/split-test-security-hardening-2026-08-16.json @@ -0,0 +1,70 @@ +{ + "intent": "Bring linux_configuration/tests/test_security_hardening.sh under the 250-line cap without changing a single assertion or its result, and finish the meta/scripts/mutate_session_autopsy_hooks.py split that a previous session left half-applied and broken in the working tree.", + "scope": [ + "linux_configuration/tests/test_security_hardening.sh (423 -> 116 lines)", + "linux_configuration/tests/lib/security_hosts_shutdown.sh (new)", + "linux_configuration/tests/lib/security_pacman.sh (new)", + "linux_configuration/tests/lib/security_apps.sh (new)", + "meta/scripts/mutate_session_autopsy_hooks.py (253 -> 175 lines)", + "meta/scripts/_session_autopsy_mutations.py (new, 99 lines)", + "Non-goal: changing, weakening, skipping or deleting any test", + "Non-goal: fixing the one pre-existing failure this suite reports on this host" + ], + "changes": [ + "Moved the five top-level test sections into three sourced libs, each section wrapped in a function and invoked from the entry script in the original order, so execution order is unchanged.", + "Kept the entry filename, since .github/workflows/shell-tests.yml runs this suite by filename.", + "Counters (PASS/FAIL/SKIP) and LIVE_SECTION still live in the entry script; sourcing shares the shell, so the libs mutate them exactly as the former top-level blocks did.", + "Added a live_section() setter in the entry script and routed the libs' LIVE_SECTION writes through it. Linted in isolation a bare assignment reads as SC2034, since its only reader (test_result) is in the entry file; the setter makes the write a real reference instead of a suppression.", + "Finished the mutate_session_autopsy_hooks.py split found half-applied in the working tree: the mutation operators had been moved to _session_autopsy_mutations.py but the driver still referenced Mutation without importing it, so mypy and pylint both failed with undefined-variable and the file could not have been committed as left." + ], + "verification": [ + { + "command": "bash -n on entry + all three libs", + "result": "pass", + "evidence": "OK test_security_hardening.sh; OK lib/security_apps.sh; OK lib/security_hosts_shutdown.sh; OK lib/security_pacman.sh" + }, + { + "command": "shellcheck lib/security_*.sh (each lib alone, no -x)", + "result": "pass", + "evidence": "LIBS CLEAN - no SC2034, no suppressions added" + }, + { + "command": "shellcheck -x test_security_hardening.sh", + "result": "pass", + "evidence": "ENTRY CLEAN" + }, + { + "command": "shfmt -d test_security_hardening.sh lib/security_*.sh", + "result": "pass", + "evidence": "SHFMT CLEAN. The pre-split entry was already shfmt-dirty; the new files are clean and were not made dirty." + }, + { + "command": "bash test_security_hardening.sh, diffed against the same suite run from a detached worktree at HEAD", + "result": "pass", + "evidence": "OUTPUT BYTE-IDENTICAL PRE/POST SPLIT after normalising the repo path prefix. Both runs: Passed 18 / Failed 1 / Skipped 5, both exit 1. The single failure is pre-existing and unrelated to this split." + }, + { + "command": "python3 meta/scripts/mutate_session_autopsy_hooks.py", + "result": "pass", + "evidence": "PASS: all 12 tests killed by at least one mutation. Ran end to end, so the bare 'from _session_autopsy_mutations import ...' resolves in the real invocation path." + }, + { + "command": "pre-commit run --files on both Python files", + "result": "pass", + "evidence": "ruff, ruff format, mypy, pylint, bandit and pytest-with-coverage all Passed after the missing Mutation import was restored; the mutation hook itself reports PASS." + }, + { + "command": "bash ~/utils/scripts/check_file_length.sh --all | grep -c 'over by'", + "result": "pass", + "evidence": "66 -> 62 violations; neither split file appears any more." + } + ], + "risks": [ + "The libs are only valid when sourced from the entry script, which owns the counters, colors and test_result(). Running a lib directly is not supported and never was a use case.", + "A future section added to a lib must call live_section() rather than assigning LIVE_SECTION, or it reintroduces SC2034." + ], + "rollback": [ + "git revert the commit; the entry script returns to a single 423-line file and the lib/ directory disappears.", + "After rollback, run bash linux_configuration/tests/test_security_hardening.sh and confirm it still reports Passed 18 / Failed 1 / Skipped 5 on this host." + ] +} diff --git a/linux_configuration/tests/lib/security_apps.sh b/linux_configuration/tests/lib/security_apps.sh new file mode 100755 index 00000000..7337d207 --- /dev/null +++ b/linux_configuration/tests/lib/security_apps.sh @@ -0,0 +1,119 @@ +#!/bin/bash +# Security hardening tests: compulsive-opening block + screen locker. +# Sourced by test_security_hardening.sh; inherits its strict mode, +# colors, counters (PASS/FAIL/SKIP), LIVE_SECTION and test_result(). + +sec_tests_compulsive_block() { + # ================================================================== + # COMPULSIVE BLOCK TESTS + # ================================================================== + echo "--- COMPULSIVE OPENING BLOCK ---" + + compulsive_file="$REPO_DIR/scripts/periodic_background/digital_wellbeing/block_compulsive_opening.sh" + + # Test 17: Auto-close timer configuration exists + if [[ -f "$compulsive_file" ]]; then + if grep -q "AUTO_CLOSE_TIMEOUT_MINUTES" "$compulsive_file"; then + test_result "Auto-close timer configuration exists" "pass" + else + test_result "Auto-close timer configuration exists" "fail" "Variable not found" + fi + else + test_result "Auto-close timer configuration exists" "skip" "Script not found" + fi + + # Test 18: launch_with_timer function exists + if [[ -f "$compulsive_file" ]]; then + if grep -q "launch_with_timer" "$compulsive_file"; then + test_result "launch_with_timer function exists" "pass" + else + test_result "launch_with_timer function exists" "fail" "Function not found" + fi + else + test_result "launch_with_timer function exists" "skip" "Script not found" + fi + + # Test 19: Compulsive block wrappers installed + wrappers_ok=true + for app in beeper signal-desktop discord; do + if [[ -f "/usr/bin/$app" ]]; then + if grep -q "block-compulsive-opening" "/usr/bin/$app" 2>/dev/null; then + : # OK + else + wrappers_ok=false + fi + fi + done + if [[ "$wrappers_ok" == true ]]; then + test_result "Compulsive block wrappers installed" "pass" + else + test_result "Compulsive block wrappers installed" "fail" "Some wrappers missing or incorrect" + fi + + echo "" + +} + +sec_tests_screen_locker() { + # ================================================================== + # SCREEN LOCKER TESTS + # ================================================================== + echo "--- SCREEN LOCKER ---" + + screen_locker="$HOME/testsAndMisc/python_pkg/screen_locker/screen_lock.py" + + # Test 20: Screen locker exists + if [[ -f "$screen_locker" ]]; then + test_result "Screen locker script exists" "pass" + else + test_result "Screen locker script exists" "skip" "Not found at expected location" + fi + + # Test 21: Running option removed + if [[ -f "$screen_locker" ]]; then + # Check that there's no "Running" button in ask_workout_type + if grep -A 20 "def ask_workout_type" "$screen_locker" | grep -q '"Running"'; then + test_result "Running workout option removed" "fail" "Still present in ask_workout_type" + else + test_result "Running workout option removed" "pass" + fi + else + test_result "Running workout option removed" "skip" "Script not found" + fi + + # Test 22: Table tennis minimum sets validation + if [[ -f "$screen_locker" ]]; then + if grep -q "MIN_TABLE_TENNIS_SETS" "$screen_locker"; then + test_result "Table tennis minimum sets validation exists" "pass" + else + test_result "Table tennis minimum sets validation exists" "fail" "Constant not found" + fi + else + test_result "Table tennis minimum sets validation exists" "skip" "Script not found" + fi + + # Test 23: Table tennis verification question + if [[ -f "$screen_locker" ]]; then + if grep -q "ask_table_tennis_verification" "$screen_locker"; then + test_result "Table tennis verification question exists" "pass" + else + test_result "Table tennis verification question exists" "fail" "Function not found" + fi + else + test_result "Table tennis verification question exists" "skip" "Script not found" + fi + + # Test 24: 60 second submit delay for table tennis + if [[ -f "$screen_locker" ]]; then + if grep -q "TABLE_TENNIS_SUBMIT_DELAY = 60" "$screen_locker"; then + test_result "Table tennis 60-second submit delay" "pass" + else + test_result "Table tennis 60-second submit delay" "fail" "Constant not set to 60" + fi + else + test_result "Table tennis 60-second submit delay" "skip" "Script not found" + fi + + echo "" + +} diff --git a/linux_configuration/tests/lib/security_hosts_shutdown.sh b/linux_configuration/tests/lib/security_hosts_shutdown.sh new file mode 100755 index 00000000..44fa68f8 --- /dev/null +++ b/linux_configuration/tests/lib/security_hosts_shutdown.sh @@ -0,0 +1,160 @@ +#!/bin/bash +# Security hardening tests: hosts guard + shutdown schedule. +# Sourced by test_security_hardening.sh; inherits its strict mode, +# colors, counters (PASS/FAIL/SKIP), LIVE_SECTION and test_result(). + +sec_tests_hosts_guard() { + # ================================================================== + # HOSTS GUARD TESTS + # ================================================================== + live_section 1 # live-host checks begin + echo "--- HOSTS GUARD ---" + + # Test 1: /etc/hosts is immutable + if [[ -f /etc/hosts ]]; then + if lsattr /etc/hosts 2>/dev/null | grep -q '^....i'; then + test_result "/etc/hosts is immutable" "pass" + else + test_result "/etc/hosts is immutable" "fail" "chattr +i not set" + fi + else + test_result "/etc/hosts is immutable" "skip" "File not found" + fi + + # Test 2: the hosts file watcher is active + # The 2026-07-17 guard-lib refactor replaced the standalone hosts-guard.path + # with the templated guard-file@hosts.path. Accept either, so this passes on a + # migrated host and on one still running the legacy unit. Checking only the old + # name reported the protection as down when it was simply renamed. + if systemctl is-active --quiet guard-file@hosts.path 2>/dev/null || + systemctl is-active --quiet hosts-guard.path 2>/dev/null; then + test_result "hosts file watcher is active" "pass" + else + test_result "hosts file watcher is active" "fail" \ + "neither guard-file@hosts.path nor hosts-guard.path is running" + fi + + # Test 3: /etc/hosts is bind-mounted read-only + # guard-bind-mount@hosts.service superseded hosts-bind-mount.service. The mount + # itself is the thing that matters, so check that too — it is the real + # invariant, independent of which unit established it. + if systemctl is-active --quiet guard-bind-mount@hosts.service 2>/dev/null || + systemctl is-active --quiet hosts-bind-mount.service 2>/dev/null || + findmnt -no OPTIONS /etc/hosts 2>/dev/null | grep -q '\bro\b'; then + test_result "/etc/hosts bind-mounted read-only" "pass" + else + test_result "/etc/hosts bind-mounted read-only" "fail" \ + "no bind-mount unit active and /etc/hosts is not mounted read-only" + fi + + # Test 4: Canonical hosts copy exists + if [[ -f /usr/local/share/locked-hosts ]]; then + test_result "Canonical hosts copy exists" "pass" + else + test_result "Canonical hosts copy exists" "fail" "Not found at /usr/local/share/locked-hosts" + fi + + # Test 5: the nsswitch watcher is active + # guard-file@nsswitch.path superseded nsswitch-guard.path. The legacy unit is + # left in a failed state on migrated hosts (it and the new watcher both + # enforced the same file, so it retriggered itself into systemd's start + # limit) — that is obsolete-unit noise, not a lapse in protection. + if systemctl is-active --quiet guard-file@nsswitch.path 2>/dev/null || + systemctl is-active --quiet nsswitch-guard.path 2>/dev/null; then + test_result "nsswitch watcher is active" "pass" + else + test_result "nsswitch watcher is active" "fail" \ + "neither guard-file@nsswitch.path nor nsswitch-guard.path is running" + fi + + # Test 6: /etc/nsswitch.conf is immutable (NEW) + if [[ -f /etc/nsswitch.conf ]]; then + if lsattr /etc/nsswitch.conf 2>/dev/null | grep -q '^....i'; then + test_result "/etc/nsswitch.conf is immutable" "pass" + else + test_result "/etc/nsswitch.conf is immutable" "fail" "chattr +i not set" + fi + else + test_result "/etc/nsswitch.conf is immutable" "skip" "File not found" + fi + + # Test 7: nsswitch.conf has correct hosts line + if [[ -f /etc/nsswitch.conf ]]; then + hosts_line=$(grep "^hosts:" /etc/nsswitch.conf 2>/dev/null || true) + if echo "$hosts_line" | grep -q 'files.*dns\|files.*myhostname'; then + test_result "nsswitch.conf has 'files' before 'dns'" "pass" + elif [[ -z "$hosts_line" ]]; then + test_result "nsswitch.conf has 'files' before 'dns'" "fail" "No hosts: line found" + else + test_result "nsswitch.conf has 'files' before 'dns'" "fail" "hosts line: $hosts_line" + fi + else + test_result "nsswitch.conf has 'files' before 'dns'" "skip" "File not found" + fi + + echo "" + +} + +sec_tests_shutdown_schedule() { + # ================================================================== + # SHUTDOWN SCHEDULE TESTS + # ================================================================== + echo "--- SHUTDOWN SCHEDULE ---" + + # Test 8: shutdown-schedule.conf is immutable + if [[ -f /etc/shutdown-schedule.conf ]]; then + if lsattr /etc/shutdown-schedule.conf 2>/dev/null | grep -q '^....i'; then + test_result "/etc/shutdown-schedule.conf is immutable" "pass" + else + test_result "/etc/shutdown-schedule.conf is immutable" "fail" "chattr +i not set" + fi + else + test_result "/etc/shutdown-schedule.conf is immutable" "skip" "Not installed" + fi + + # Test 9: shutdown timer is active + if systemctl is-active --quiet day-specific-shutdown.timer 2>/dev/null; then + test_result "day-specific-shutdown.timer is active" "pass" + else + test_result "day-specific-shutdown.timer is active" "fail" "Timer not running" + fi + + # Test 10: shutdown schedule guard + # The shutdown flow no longer powers the machine off — day-specific-shutdown + # now hands over to night-lockdown-enter.sh, which tears down the GUI and masks + # the TTY login while leaving the servers up. This guard was retired with the + # old poweroff path, so a *disabled* unit is the intended state, not a fault. + # An enabled-but-inactive unit would still mean something is wrong, so those + # two cases are distinguished rather than both being tolerated. + # `systemctl is-enabled` prints the state on stdout *and* exits non-zero when + # that state is "disabled", so `|| echo disabled` would append a second line + # and never compare equal. Capture stdout and discard the exit status instead. + shutdown_guard_enabled=$(systemctl is-enabled shutdown-schedule-guard.path 2>/dev/null || true) + [[ -n "$shutdown_guard_enabled" ]] || shutdown_guard_enabled=unknown + if systemctl is-active --quiet guard-file@shutdown-schedule.path 2>/dev/null || + systemctl is-active --quiet shutdown-schedule-guard.path 2>/dev/null; then + # guard-file@shutdown-schedule.path is the guard-lib replacement and is the + # unit actually protecting the schedule today, so this is a pass, not a + # concession that the schedule went unguarded. + test_result "shutdown schedule is guarded" "pass" + elif [[ "$shutdown_guard_enabled" == "disabled" ]]; then + test_result "shutdown-schedule-guard.path is active" "skip" \ + "intentionally retired with the old poweroff flow (night lockdown replaced it)" + else + test_result "shutdown-schedule-guard.path is active" "fail" \ + "unit is $shutdown_guard_enabled but not running" + fi + + # Test 11: Unlock script has obscure name (no helpful path) + if [[ -f /usr/local/sbin/.sd-sched-mgmt ]]; then + test_result "Unlock script uses obscure name" "pass" + elif [[ -f /usr/local/sbin/unlock-shutdown-schedule ]]; then + test_result "Unlock script uses obscure name" "fail" "Still using obvious name" + else + test_result "Unlock script uses obscure name" "skip" "Not installed" + fi + + echo "" + +} diff --git a/linux_configuration/tests/lib/security_pacman.sh b/linux_configuration/tests/lib/security_pacman.sh new file mode 100755 index 00000000..4028f97c --- /dev/null +++ b/linux_configuration/tests/lib/security_pacman.sh @@ -0,0 +1,73 @@ +#!/bin/bash +# Security hardening tests: pacman wrapper and its policy files. +# Sourced by test_security_hardening.sh; inherits its strict mode, +# colors, counters (PASS/FAIL/SKIP), LIVE_SECTION and test_result(). + +sec_tests_pacman_wrapper() { + # ================================================================== + # PACMAN WRAPPER TESTS + # ================================================================== + echo "--- PACMAN WRAPPER ---" + + # Test 12: pacman wrapper is installed + if [[ -L /usr/bin/pacman ]] && [[ -f /usr/bin/pacman.orig ]]; then + test_result "pacman wrapper installed" "pass" + else + if [[ ! -L /usr/bin/pacman ]]; then + test_result "pacman wrapper installed" "fail" "/usr/bin/pacman is not a symlink" + else + test_result "pacman wrapper installed" "fail" "/usr/bin/pacman.orig not found" + fi + fi + + live_section 0 # repository checks from here on + + # Test 13: google-chrome is blocked + blocked_file="$REPO_DIR/scripts/periodic_background/digital_wellbeing/pacman/pacman_blocked_keywords.txt" + if [[ -f "$blocked_file" ]]; then + if grep -qi "google-chrome" "$blocked_file"; then + test_result "google-chrome in blocked list" "pass" + else + test_result "google-chrome in blocked list" "fail" "Not found in $blocked_file" + fi + else + test_result "google-chrome in blocked list" "skip" "Blocked keywords file not found" + fi + + # Test 14: chromium is blocked + if [[ -f "$blocked_file" ]]; then + if grep -qi "^chromium$" "$blocked_file"; then + test_result "chromium in blocked list" "pass" + else + test_result "chromium in blocked list" "fail" "Not found in $blocked_file" + fi + else + test_result "chromium in blocked list" "skip" "Blocked keywords file not found" + fi + + # Test 15: Policy integrity file exists + # Lives under /var on the configured host, so it is a live check despite + # sitting among the repository ones. + live_section 1 + if [[ -f /var/lib/pacman-wrapper/policy.sha256 ]]; then + test_result "Pacman policy integrity file exists" "pass" + else + test_result "Pacman policy integrity file exists" "fail" "Not found" + fi + live_section 0 + + # Test 16: LeechBlock auto-install function exists in wrapper + wrapper_file="$REPO_DIR/scripts/periodic_background/digital_wellbeing/pacman/pacman_wrapper.sh" + if [[ -f "$wrapper_file" ]]; then + if grep -q "auto_install_leechblock" "$wrapper_file"; then + test_result "LeechBlock auto-install function exists" "pass" + else + test_result "LeechBlock auto-install function exists" "fail" "Function not found" + fi + else + test_result "LeechBlock auto-install function exists" "skip" "Wrapper file not found" + fi + + echo "" + +} diff --git a/linux_configuration/tests/test_security_hardening.sh b/linux_configuration/tests/test_security_hardening.sh index fd0c9a50..dc592f75 100755 --- a/linux_configuration/tests/test_security_hardening.sh +++ b/linux_configuration/tests/test_security_hardening.sh @@ -66,6 +66,14 @@ test_result() { esac } +# Setter for LIVE_SECTION. The section libs are linted in isolation, where a +# bare `LIVE_SECTION=1` looks like an unused variable (SC2034) because its only +# reader — test_result, above — lives in this file. Going through a function +# makes the write a real reference instead of needing a suppression. +live_section() { + LIVE_SECTION="$1" +} + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_DIR="$(dirname "$SCRIPT_DIR")" @@ -76,330 +84,23 @@ echo "" echo "Testing components in: $REPO_DIR" echo "" -# ================================================================== -# HOSTS GUARD TESTS -# ================================================================== -LIVE_SECTION=1 # live-host checks begin -echo "--- HOSTS GUARD ---" +# The test sections live in libs to keep this file under the 250-line cap. +# They are functions, but they mutate this script's counters and LIVE_SECTION +# exactly as the former top-level blocks did — sourcing shares the shell. +LIB_DIR="$SCRIPT_DIR/lib" +# shellcheck source=lib/security_hosts_shutdown.sh +source "$LIB_DIR/security_hosts_shutdown.sh" +# shellcheck source=lib/security_pacman.sh +source "$LIB_DIR/security_pacman.sh" +# shellcheck source=lib/security_apps.sh +source "$LIB_DIR/security_apps.sh" -# Test 1: /etc/hosts is immutable -if [[ -f /etc/hosts ]]; then - if lsattr /etc/hosts 2>/dev/null | grep -q '^....i'; then - test_result "/etc/hosts is immutable" "pass" - else - test_result "/etc/hosts is immutable" "fail" "chattr +i not set" - fi -else - test_result "/etc/hosts is immutable" "skip" "File not found" -fi - -# Test 2: the hosts file watcher is active -# The 2026-07-17 guard-lib refactor replaced the standalone hosts-guard.path -# with the templated guard-file@hosts.path. Accept either, so this passes on a -# migrated host and on one still running the legacy unit. Checking only the old -# name reported the protection as down when it was simply renamed. -if systemctl is-active --quiet guard-file@hosts.path 2>/dev/null || - systemctl is-active --quiet hosts-guard.path 2>/dev/null; then - test_result "hosts file watcher is active" "pass" -else - test_result "hosts file watcher is active" "fail" \ - "neither guard-file@hosts.path nor hosts-guard.path is running" -fi - -# Test 3: /etc/hosts is bind-mounted read-only -# guard-bind-mount@hosts.service superseded hosts-bind-mount.service. The mount -# itself is the thing that matters, so check that too — it is the real -# invariant, independent of which unit established it. -if systemctl is-active --quiet guard-bind-mount@hosts.service 2>/dev/null || - systemctl is-active --quiet hosts-bind-mount.service 2>/dev/null || - findmnt -no OPTIONS /etc/hosts 2>/dev/null | grep -q '\bro\b'; then - test_result "/etc/hosts bind-mounted read-only" "pass" -else - test_result "/etc/hosts bind-mounted read-only" "fail" \ - "no bind-mount unit active and /etc/hosts is not mounted read-only" -fi - -# Test 4: Canonical hosts copy exists -if [[ -f /usr/local/share/locked-hosts ]]; then - test_result "Canonical hosts copy exists" "pass" -else - test_result "Canonical hosts copy exists" "fail" "Not found at /usr/local/share/locked-hosts" -fi - -# Test 5: the nsswitch watcher is active -# guard-file@nsswitch.path superseded nsswitch-guard.path. The legacy unit is -# left in a failed state on migrated hosts (it and the new watcher both -# enforced the same file, so it retriggered itself into systemd's start -# limit) — that is obsolete-unit noise, not a lapse in protection. -if systemctl is-active --quiet guard-file@nsswitch.path 2>/dev/null || - systemctl is-active --quiet nsswitch-guard.path 2>/dev/null; then - test_result "nsswitch watcher is active" "pass" -else - test_result "nsswitch watcher is active" "fail" \ - "neither guard-file@nsswitch.path nor nsswitch-guard.path is running" -fi - -# Test 6: /etc/nsswitch.conf is immutable (NEW) -if [[ -f /etc/nsswitch.conf ]]; then - if lsattr /etc/nsswitch.conf 2>/dev/null | grep -q '^....i'; then - test_result "/etc/nsswitch.conf is immutable" "pass" - else - test_result "/etc/nsswitch.conf is immutable" "fail" "chattr +i not set" - fi -else - test_result "/etc/nsswitch.conf is immutable" "skip" "File not found" -fi - -# Test 7: nsswitch.conf has correct hosts line -if [[ -f /etc/nsswitch.conf ]]; then - hosts_line=$(grep "^hosts:" /etc/nsswitch.conf 2>/dev/null || true) - if echo "$hosts_line" | grep -q 'files.*dns\|files.*myhostname'; then - test_result "nsswitch.conf has 'files' before 'dns'" "pass" - elif [[ -z "$hosts_line" ]]; then - test_result "nsswitch.conf has 'files' before 'dns'" "fail" "No hosts: line found" - else - test_result "nsswitch.conf has 'files' before 'dns'" "fail" "hosts line: $hosts_line" - fi -else - test_result "nsswitch.conf has 'files' before 'dns'" "skip" "File not found" -fi - -echo "" - -# ================================================================== -# SHUTDOWN SCHEDULE TESTS -# ================================================================== -echo "--- SHUTDOWN SCHEDULE ---" - -# Test 8: shutdown-schedule.conf is immutable -if [[ -f /etc/shutdown-schedule.conf ]]; then - if lsattr /etc/shutdown-schedule.conf 2>/dev/null | grep -q '^....i'; then - test_result "/etc/shutdown-schedule.conf is immutable" "pass" - else - test_result "/etc/shutdown-schedule.conf is immutable" "fail" "chattr +i not set" - fi -else - test_result "/etc/shutdown-schedule.conf is immutable" "skip" "Not installed" -fi - -# Test 9: shutdown timer is active -if systemctl is-active --quiet day-specific-shutdown.timer 2>/dev/null; then - test_result "day-specific-shutdown.timer is active" "pass" -else - test_result "day-specific-shutdown.timer is active" "fail" "Timer not running" -fi - -# Test 10: shutdown schedule guard -# The shutdown flow no longer powers the machine off — day-specific-shutdown -# now hands over to night-lockdown-enter.sh, which tears down the GUI and masks -# the TTY login while leaving the servers up. This guard was retired with the -# old poweroff path, so a *disabled* unit is the intended state, not a fault. -# An enabled-but-inactive unit would still mean something is wrong, so those -# two cases are distinguished rather than both being tolerated. -# `systemctl is-enabled` prints the state on stdout *and* exits non-zero when -# that state is "disabled", so `|| echo disabled` would append a second line -# and never compare equal. Capture stdout and discard the exit status instead. -shutdown_guard_enabled=$(systemctl is-enabled shutdown-schedule-guard.path 2>/dev/null || true) -[[ -n "$shutdown_guard_enabled" ]] || shutdown_guard_enabled=unknown -if systemctl is-active --quiet guard-file@shutdown-schedule.path 2>/dev/null || - systemctl is-active --quiet shutdown-schedule-guard.path 2>/dev/null; then - # guard-file@shutdown-schedule.path is the guard-lib replacement and is the - # unit actually protecting the schedule today, so this is a pass, not a - # concession that the schedule went unguarded. - test_result "shutdown schedule is guarded" "pass" -elif [[ "$shutdown_guard_enabled" == "disabled" ]]; then - test_result "shutdown-schedule-guard.path is active" "skip" \ - "intentionally retired with the old poweroff flow (night lockdown replaced it)" -else - test_result "shutdown-schedule-guard.path is active" "fail" \ - "unit is $shutdown_guard_enabled but not running" -fi - -# Test 11: Unlock script has obscure name (no helpful path) -if [[ -f /usr/local/sbin/.sd-sched-mgmt ]]; then - test_result "Unlock script uses obscure name" "pass" -elif [[ -f /usr/local/sbin/unlock-shutdown-schedule ]]; then - test_result "Unlock script uses obscure name" "fail" "Still using obvious name" -else - test_result "Unlock script uses obscure name" "skip" "Not installed" -fi - -echo "" - -# ================================================================== -# PACMAN WRAPPER TESTS -# ================================================================== -echo "--- PACMAN WRAPPER ---" - -# Test 12: pacman wrapper is installed -if [[ -L /usr/bin/pacman ]] && [[ -f /usr/bin/pacman.orig ]]; then - test_result "pacman wrapper installed" "pass" -else - if [[ ! -L /usr/bin/pacman ]]; then - test_result "pacman wrapper installed" "fail" "/usr/bin/pacman is not a symlink" - else - test_result "pacman wrapper installed" "fail" "/usr/bin/pacman.orig not found" - fi -fi - -LIVE_SECTION=0 # repository checks from here on - -# Test 13: google-chrome is blocked -blocked_file="$REPO_DIR/scripts/periodic_background/digital_wellbeing/pacman/pacman_blocked_keywords.txt" -if [[ -f "$blocked_file" ]]; then - if grep -qi "google-chrome" "$blocked_file"; then - test_result "google-chrome in blocked list" "pass" - else - test_result "google-chrome in blocked list" "fail" "Not found in $blocked_file" - fi -else - test_result "google-chrome in blocked list" "skip" "Blocked keywords file not found" -fi - -# Test 14: chromium is blocked -if [[ -f "$blocked_file" ]]; then - if grep -qi "^chromium$" "$blocked_file"; then - test_result "chromium in blocked list" "pass" - else - test_result "chromium in blocked list" "fail" "Not found in $blocked_file" - fi -else - test_result "chromium in blocked list" "skip" "Blocked keywords file not found" -fi - -# Test 15: Policy integrity file exists -# Lives under /var on the configured host, so it is a live check despite -# sitting among the repository ones. -LIVE_SECTION=1 -if [[ -f /var/lib/pacman-wrapper/policy.sha256 ]]; then - test_result "Pacman policy integrity file exists" "pass" -else - test_result "Pacman policy integrity file exists" "fail" "Not found" -fi -LIVE_SECTION=0 - -# Test 16: LeechBlock auto-install function exists in wrapper -wrapper_file="$REPO_DIR/scripts/periodic_background/digital_wellbeing/pacman/pacman_wrapper.sh" -if [[ -f "$wrapper_file" ]]; then - if grep -q "auto_install_leechblock" "$wrapper_file"; then - test_result "LeechBlock auto-install function exists" "pass" - else - test_result "LeechBlock auto-install function exists" "fail" "Function not found" - fi -else - test_result "LeechBlock auto-install function exists" "skip" "Wrapper file not found" -fi - -echo "" - -# ================================================================== -# COMPULSIVE BLOCK TESTS -# ================================================================== -echo "--- COMPULSIVE OPENING BLOCK ---" - -compulsive_file="$REPO_DIR/scripts/periodic_background/digital_wellbeing/block_compulsive_opening.sh" - -# Test 17: Auto-close timer configuration exists -if [[ -f "$compulsive_file" ]]; then - if grep -q "AUTO_CLOSE_TIMEOUT_MINUTES" "$compulsive_file"; then - test_result "Auto-close timer configuration exists" "pass" - else - test_result "Auto-close timer configuration exists" "fail" "Variable not found" - fi -else - test_result "Auto-close timer configuration exists" "skip" "Script not found" -fi - -# Test 18: launch_with_timer function exists -if [[ -f "$compulsive_file" ]]; then - if grep -q "launch_with_timer" "$compulsive_file"; then - test_result "launch_with_timer function exists" "pass" - else - test_result "launch_with_timer function exists" "fail" "Function not found" - fi -else - test_result "launch_with_timer function exists" "skip" "Script not found" -fi - -# Test 19: Compulsive block wrappers installed -wrappers_ok=true -for app in beeper signal-desktop discord; do - if [[ -f "/usr/bin/$app" ]]; then - if grep -q "block-compulsive-opening" "/usr/bin/$app" 2>/dev/null; then - : # OK - else - wrappers_ok=false - fi - fi -done -if [[ "$wrappers_ok" == true ]]; then - test_result "Compulsive block wrappers installed" "pass" -else - test_result "Compulsive block wrappers installed" "fail" "Some wrappers missing or incorrect" -fi - -echo "" - -# ================================================================== -# SCREEN LOCKER TESTS -# ================================================================== -echo "--- SCREEN LOCKER ---" - -screen_locker="$HOME/testsAndMisc/python_pkg/screen_locker/screen_lock.py" - -# Test 20: Screen locker exists -if [[ -f "$screen_locker" ]]; then - test_result "Screen locker script exists" "pass" -else - test_result "Screen locker script exists" "skip" "Not found at expected location" -fi - -# Test 21: Running option removed -if [[ -f "$screen_locker" ]]; then - # Check that there's no "Running" button in ask_workout_type - if grep -A 20 "def ask_workout_type" "$screen_locker" | grep -q '"Running"'; then - test_result "Running workout option removed" "fail" "Still present in ask_workout_type" - else - test_result "Running workout option removed" "pass" - fi -else - test_result "Running workout option removed" "skip" "Script not found" -fi - -# Test 22: Table tennis minimum sets validation -if [[ -f "$screen_locker" ]]; then - if grep -q "MIN_TABLE_TENNIS_SETS" "$screen_locker"; then - test_result "Table tennis minimum sets validation exists" "pass" - else - test_result "Table tennis minimum sets validation exists" "fail" "Constant not found" - fi -else - test_result "Table tennis minimum sets validation exists" "skip" "Script not found" -fi - -# Test 23: Table tennis verification question -if [[ -f "$screen_locker" ]]; then - if grep -q "ask_table_tennis_verification" "$screen_locker"; then - test_result "Table tennis verification question exists" "pass" - else - test_result "Table tennis verification question exists" "fail" "Function not found" - fi -else - test_result "Table tennis verification question exists" "skip" "Script not found" -fi - -# Test 24: 60 second submit delay for table tennis -if [[ -f "$screen_locker" ]]; then - if grep -q "TABLE_TENNIS_SUBMIT_DELAY = 60" "$screen_locker"; then - test_result "Table tennis 60-second submit delay" "pass" - else - test_result "Table tennis 60-second submit delay" "fail" "Constant not set to 60" - fi -else - test_result "Table tennis 60-second submit delay" "skip" "Script not found" -fi - -echo "" +# Order matters: it is the order the sections ran in before the split. +sec_tests_hosts_guard +sec_tests_shutdown_schedule +sec_tests_pacman_wrapper +sec_tests_compulsive_block +sec_tests_screen_locker # ================================================================== # SUMMARY diff --git a/meta/scripts/_session_autopsy_mutations.py b/meta/scripts/_session_autopsy_mutations.py new file mode 100755 index 00000000..167349ed --- /dev/null +++ b/meta/scripts/_session_autopsy_mutations.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +"""The mutation operators: one named defect each, injected into a hook copy. + +Split out of ``mutate_session_autopsy_hooks.py`` to keep both files under the +repository's 250-line cap. Every function here takes the text of one hook and +returns it with exactly one guard broken; the driver applies them one at a time +and records which tests notice. + +Nothing here touches the filesystem -- these are pure text transforms, which is +what makes the driver's "the deployed hooks are never modified" guarantee easy +to see. +""" + +from __future__ import annotations + +import re +from typing import TYPE_CHECKING, NamedTuple + +if TYPE_CHECKING: + from collections.abc import Callable + +END_HOOK = "session_autopsy_end.sh" +START_HOOK = "session_autopsy_start.sh" +_GUTTED = "#!/bin/bash\nexit 0\n" + + +def _gut(_text: str) -> str: + """Replace an entire hook body with a bare successful exit.""" + return _GUTTED + + +def _drop_transcript_guard(text: str) -> str: + """Remove the SessionEnd guard that requires an existing transcript.""" + return re.sub( + r'if \[\[ -z "\$transcript".*?\nfi\n', + "", + text, + flags=re.DOTALL, + ) + + +def _widen_count(text: str) -> str: + """Make a zero candidate count announce itself.""" + return text.replace("(( count > 0 ))", "(( count >= 0 ))") + + +def _drop_numeric_guard(text: str) -> str: + """Drop the count regex guard, leaving the arithmetic syntactically valid.""" + return text.replace( + '[[ "$count" =~ ^[0-9]+$ ]] && (( count > 0 ))', + "(( count > 0 ))", + ) + + +def _drop_readable_guard(text: str) -> str: + """Remove the early exit taken when the state file cannot be read.""" + return re.sub( + r'if \[\[ ! -r "\$STATE" \]\]; then\n.*?\nfi\n', + "", + text, + flags=re.DOTALL, + ) + + +def _always_announce(text: str) -> str: + """Announce unconditionally -- the defect every silence test exists to catch.""" + return re.sub(r'if \[\[ "\$count".*?; then', "if true; then", text) + + +def _unguarded_and_always_announce(text: str) -> str: + """Drop the readability guard AND announce unconditionally. + + Needed because the readability guard is redundant on its own: jq's stderr is + suppressed and an empty count already fails the arithmetic, so removing it + changes nothing observable. Only removing it together with the count + condition can reach the no-state and unreadable-state cases. + """ + return _always_announce(_drop_readable_guard(text)) + + +class Mutation(NamedTuple): + """One named defect injected into one hook.""" + + name: str + hook: str + apply: Callable[[str], str] + + +MUTATIONS = ( + Mutation("gut-end", END_HOOK, _gut), + Mutation("gut-start", START_HOOK, _gut), + Mutation("end-no-transcript-guard", END_HOOK, _drop_transcript_guard), + Mutation("start-count-ge-zero", START_HOOK, _widen_count), + Mutation("start-no-numeric-guard", START_HOOK, _drop_numeric_guard), + Mutation("start-no-readable-guard", START_HOOK, _drop_readable_guard), + Mutation("start-always-announce", START_HOOK, _always_announce), + Mutation("start-unguarded-announce", START_HOOK, _unguarded_and_always_announce), +) + +__all__ = ["END_HOOK", "MUTATIONS", "START_HOOK", "Mutation"] diff --git a/meta/scripts/mutate_session_autopsy_hooks.py b/meta/scripts/mutate_session_autopsy_hooks.py index b8c31b10..8fdc8f92 100755 --- a/meta/scripts/mutate_session_autopsy_hooks.py +++ b/meta/scripts/mutate_session_autopsy_hooks.py @@ -32,15 +32,10 @@ import shutil import subprocess import sys import tempfile -from typing import TYPE_CHECKING, NamedTuple -if TYPE_CHECKING: - from collections.abc import Callable +from _session_autopsy_mutations import END_HOOK, MUTATIONS, START_HOOK, Mutation _TEST_FILE = "linux_configuration/tests/test_session_autopsy_hooks.py" -_END_HOOK = "session_autopsy_end.sh" -_START_HOOK = "session_autopsy_start.sh" -_GUTTED = "#!/bin/bash\nexit 0\n" _OUTCOME = re.compile(r"::(test_\w+)\s+(PASSED|FAILED|ERROR|SKIPPED)") @@ -57,80 +52,6 @@ def _hooks_dir() -> Path: return Path.home() / ".claude" / "hooks" -def _gut(_text: str) -> str: - """Replace an entire hook body with a bare successful exit.""" - return _GUTTED - - -def _drop_transcript_guard(text: str) -> str: - """Remove the SessionEnd guard that requires an existing transcript.""" - return re.sub( - r'if \[\[ -z "\$transcript".*?\nfi\n', - "", - text, - flags=re.DOTALL, - ) - - -def _widen_count(text: str) -> str: - """Make a zero candidate count announce itself.""" - return text.replace("(( count > 0 ))", "(( count >= 0 ))") - - -def _drop_numeric_guard(text: str) -> str: - """Drop the count regex guard, leaving the arithmetic syntactically valid.""" - return text.replace( - '[[ "$count" =~ ^[0-9]+$ ]] && (( count > 0 ))', - "(( count > 0 ))", - ) - - -def _drop_readable_guard(text: str) -> str: - """Remove the early exit taken when the state file cannot be read.""" - return re.sub( - r'if \[\[ ! -r "\$STATE" \]\]; then\n.*?\nfi\n', - "", - text, - flags=re.DOTALL, - ) - - -def _always_announce(text: str) -> str: - """Announce unconditionally -- the defect every silence test exists to catch.""" - return re.sub(r'if \[\[ "\$count".*?; then', "if true; then", text) - - -def _unguarded_and_always_announce(text: str) -> str: - """Drop the readability guard AND announce unconditionally. - - Needed because the readability guard is redundant on its own: jq's stderr is - suppressed and an empty count already fails the arithmetic, so removing it - changes nothing observable. Only removing it together with the count - condition can reach the no-state and unreadable-state cases. - """ - return _always_announce(_drop_readable_guard(text)) - - -class Mutation(NamedTuple): - """One named defect injected into one hook.""" - - name: str - hook: str - apply: Callable[[str], str] - - -_MUTATIONS = ( - Mutation("gut-end", _END_HOOK, _gut), - Mutation("gut-start", _START_HOOK, _gut), - Mutation("end-no-transcript-guard", _END_HOOK, _drop_transcript_guard), - Mutation("start-count-ge-zero", _START_HOOK, _widen_count), - Mutation("start-no-numeric-guard", _START_HOOK, _drop_numeric_guard), - Mutation("start-no-readable-guard", _START_HOOK, _drop_readable_guard), - Mutation("start-always-announce", _START_HOOK, _always_announce), - Mutation("start-unguarded-announce", _START_HOOK, _unguarded_and_always_announce), -) - - def _repo_root() -> Path: """Return the repository root (this file lives in ``meta/scripts``).""" return Path(__file__).resolve().parents[2] @@ -197,7 +118,7 @@ def _apply(mutation: Mutation, hooks_dir: Path, target_dir: Path) -> None: RuntimeError: If the mutation left the hook text unchanged, which means the hook was edited and the mutation no longer describes it. """ - for hook in (_END_HOOK, _START_HOOK): + for hook in (END_HOOK, START_HOOK): shutil.copy2(hooks_dir / hook, target_dir / hook) target = target_dir / mutation.hook original = target.read_text(encoding="utf-8") @@ -215,7 +136,7 @@ def _apply(mutation: Mutation, hooks_dir: Path, target_dir: Path) -> None: def main() -> int: """Run every mutation and fail if any test survives them all.""" hooks_dir = _hooks_dir() - if not all((hooks_dir / hook).is_file() for hook in (_END_HOOK, _START_HOOK)): + if not all((hooks_dir / hook).is_file() for hook in (END_HOOK, START_HOOK)): _emit(f"session_autopsy hooks are not deployed under {hooks_dir} -- skipping") return 0 @@ -227,7 +148,7 @@ def main() -> int: return 1 killed_by: dict[str, list[str]] = {} - for mutation in _MUTATIONS: + for mutation in MUTATIONS: try: with tempfile.TemporaryDirectory() as mutant_name: mutant_dir = Path(mutant_name)