diff --git a/docs/superpowers/contracts/tether-enforcer-split-2026-08-17.json b/docs/superpowers/contracts/tether-enforcer-split-2026-08-17.json new file mode 100644 index 00000000..381072af --- /dev/null +++ b/docs/superpowers/contracts/tether-enforcer-split-2026-08-17.json @@ -0,0 +1,17 @@ +{ + "title": "Split tether_enforcer.sh, the first phone_focus_mode target", + "objective": "tether_enforcer.sh is 17 lines over the cap - the cheapest file left. It is also the first split in phone_focus_mode, where deploy.sh copies a hardcoded per-file list into REMOTE_DIR and never deploys lib/, so the seam must be a sibling file and both of deploy.sh's lists must grow in the same commit. Establish that pattern here, where a runnable test can prove it.", + "acceptance_criteria": [ + "Both resulting files are under 250 lines; the over-cap total falls 25 -> 24", + "All 20 top-level functions are verbatim", + "test_tether_enforcer.sh reports 17 passed, 0 failed - the same as the pre-split baseline", + "deploy.sh pushes AND copies the new sibling, mirroring how the entry script is handled", + "shellcheck passes on the sibling standalone with no suppression" + ], + "out_of_scope": [ + "Deploying to the phone, which needs the device and a focus-mode window", + "The other seven phone_focus_mode scripts", + "The pacman-wrapper pair" + ], + "verifier": "bash phone_focus_mode/lib/tests/test_tether_enforcer.sh for the behaviour claim; bash meta/scripts/verify_shell_split.sh for the verbatim claim; grep for the sibling in both of deploy.sh's lists; shellcheck standalone; bash ~/utils/scripts/check_file_length.sh --all." +} diff --git a/docs/superpowers/evidence/tether-enforcer-split-2026-08-17.json b/docs/superpowers/evidence/tether-enforcer-split-2026-08-17.json new file mode 100644 index 00000000..205fe0d0 --- /dev/null +++ b/docs/superpowers/evidence/tether-enforcer-split-2026-08-17.json @@ -0,0 +1,56 @@ +{ + "intent": "Split tether_enforcer.sh (267) below the cap, as the first phone_focus_mode split, and prove the deployment copy lists were updated so the daemon still finds its sibling on the phone.", + "scope": [ + "phone_focus_mode/tether_enforcer.sh 267 -> 182", + "New: phone_focus_mode/tether_iptables.sh 103", + "phone_focus_mode/deploy.sh: added the sibling to both hardcoded lists", + "phone_focus_mode/lib/tests/test_tether_enforcer.sh: copy the sibling into the scratch dir", + "Non-goal: any behaviour change" + ], + "changes": [ + "Moved the nine iptables and tethering-offload functions into a sibling file, not a lib/ member: deploy.sh copies a hardcoded per-file list into REMOTE_DIR and never deploys lib/, so anything sourced on the phone must sit beside the entry script.", + "deploy.sh has TWO hardcoded lists - an adb push into the staging dir and an adb_root cp into REMOTE_DIR. Both were updated. Missing the push would have left the copy pointing at a file that was never staged.", + "The test harness copies only the entry script into a scratch dir, so it was taught to copy the sibling too - the same coupling as the phone deployment, in a form that can actually be run.", + "This script is #!/system/bin/sh with `shell=ash` and has no `set -e`, so extract_shell_functions.py exited rather than guessing an anchor. The source line was placed by hand after the existing `. \"$SCRIPT_DIR/config.sh\"`, and the functions were lifted with the same brace-matching helper the tool uses." + ], + "verification": [ + { + "command": "bash phone_focus_mode/lib/tests/test_tether_enforcer.sh, before and after", + "result": "pass", + "evidence": "17 passed, 0 failed both times. This is a real run of the daemon against fakes for iptables, settings and the state dir - it exercises the sibling source the same way the phone will, which is the strongest evidence available without the device." + }, + { + "command": "bash meta/scripts/verify_shell_split.sh HEAD tether_enforcer.sh tether_iptables.sh tether_enforcer.sh", + "result": "pass", + "evidence": "IDENTICAL: all 20 top-level functions moved verbatim." + }, + { + "command": "grep -n tether_iptables phone_focus_mode/deploy.sh", + "result": "pass", + "evidence": "Two hits: line 382 pushes it to the staging dir, line 499 copies it into REMOTE_DIR - mirroring exactly how tether_enforcer.sh itself is handled." + }, + { + "command": "shellcheck on the new sibling standalone", + "result": "pass", + "evidence": "Clean, with no suppression. The file is marked `shell=ash` to match the entry script's Android shell." + }, + { + "command": "pre-commit run --files ", + "result": "pass", + "evidence": "shellcheck and shfmt pass." + }, + { + "command": "bash ~/utils/scripts/check_file_length.sh --all", + "result": "pass", + "evidence": "24 files over cap, down from 25." + } + ], + "risks": [ + "Not verified on the phone. The deploy path was not run, because deploying requires the device and a focus-mode window; the copy-list edits are verified by inspection plus the test harness, which reproduces the same source-a-sibling arrangement. The next deploy is where this gets confirmed - if tether enforcement stops working after it, this commit is the first place to look.", + "The test harness now mirrors the deployment's file list in a third place. If a future split adds another sibling, all three lists must grow together." + ], + "rollback": [ + "git revert the commit; the entry script returns to 267 lines, the sibling disappears, and both deploy.sh lists shrink back.", + "After rollback, re-run test_tether_enforcer.sh and expect 17 passed." + ] +} diff --git a/phone_focus_mode/deploy.sh b/phone_focus_mode/deploy.sh index 64461ef9..fbc6ecc6 100755 --- a/phone_focus_mode/deploy.sh +++ b/phone_focus_mode/deploy.sh @@ -379,6 +379,7 @@ do_deploy() { adb_cmd push "$DEPLOY_DIR/launcher_enforcer.sh" "/data/local/tmp/focus_stage/launcher_enforcer.sh" adb_cmd push "$DEPLOY_DIR/curfew_enforcer.sh" "/data/local/tmp/focus_stage/curfew_enforcer.sh" adb_cmd push "$DEPLOY_DIR/tether_enforcer.sh" "/data/local/tmp/focus_stage/tether_enforcer.sh" + adb_cmd push "$DEPLOY_DIR/tether_iptables.sh" "/data/local/tmp/focus_stage/tether_iptables.sh" adb_cmd push "$DEPLOY_DIR/workout_detector.sh" "/data/local/tmp/focus_stage/workout_detector.sh" adb_cmd push "$DEPLOY_DIR/magisk_service.sh" "/data/local/tmp/focus_stage/99-focus-mode.sh" @@ -495,6 +496,7 @@ do_deploy() { adb_root "cp /data/local/tmp/focus_stage/launcher_enforcer.sh $REMOTE_DIR/launcher_enforcer.sh" adb_root "cp /data/local/tmp/focus_stage/curfew_enforcer.sh $REMOTE_DIR/curfew_enforcer.sh" adb_root "cp /data/local/tmp/focus_stage/tether_enforcer.sh $REMOTE_DIR/tether_enforcer.sh" + adb_root "cp /data/local/tmp/focus_stage/tether_iptables.sh $REMOTE_DIR/tether_iptables.sh" adb_root "cp /data/local/tmp/focus_stage/workout_detector.sh $REMOTE_DIR/workout_detector.sh" if adb_cmd shell "test -f /data/local/tmp/focus_stage/sqlite3" 2>/dev/null; then adb_root "cp /data/local/tmp/focus_stage/sqlite3 $REMOTE_DIR/sqlite3" diff --git a/phone_focus_mode/lib/tests/test_tether_enforcer.sh b/phone_focus_mode/lib/tests/test_tether_enforcer.sh index c4850b26..4204c6e2 100755 --- a/phone_focus_mode/lib/tests/test_tether_enforcer.sh +++ b/phone_focus_mode/lib/tests/test_tether_enforcer.sh @@ -24,6 +24,9 @@ STATE="$RUN/state" # --- fake config.sh in the daemon's own dir (it sources "$SCRIPT_DIR/config.sh") --- cp "$SRC" "$RUN/app/tether_enforcer.sh" +# tether_enforcer sources this sibling; the phone gets it from deploy.sh's +# copy list, and this harness has to mirror that or the daemon dies on source. +cp "$(dirname "$SRC")/tether_iptables.sh" "$RUN/app/tether_iptables.sh" cat >"$RUN/app/config.sh" </dev/null)" - if [ "$cur" != "1" ]; then - settings put global "$TETHER_OFFLOAD_KEY" 1 2>/dev/null \ - && log "tether offload was '$cur' - forced disabled (=1)" - fi -} -snapshot_offload() { - local cur - cur="$(settings get global "$TETHER_OFFLOAD_KEY" 2>/dev/null)" - printf '%s\n' "${cur:-null}" > "$TETHER_OFFLOAD_SNAP" 2>/dev/null || true -} -restore_offload() { - local snap - [ -f "$TETHER_OFFLOAD_SNAP" ] && snap="$(cat "$TETHER_OFFLOAD_SNAP" 2>/dev/null)" - case "$snap" in - ''|null) - # No pre-existing value: clear ours so the OS default returns. - settings delete global "$TETHER_OFFLOAD_KEY" 2>/dev/null || true - ;; - *) - settings put global "$TETHER_OFFLOAD_KEY" "$snap" 2>/dev/null || true - ;; - esac -} # ---- Lever 2: FORWARD chain blanket REJECT ---- -# Run iptables/ip6tables with a 2s xtables lock-wait so our calls queue for the -# lock instead of silently failing the instant netd holds it (proven necessary -# on-device for the curfew net layer). $1 = binary. -iptw() { - local bin="$1" - shift - "$bin" -w 2 "$@" -} -ensure_chain() { - local ipt="$1" - if ! iptw "$ipt" -L "$TETHER_IPT_CHAIN" >/dev/null 2>&1; then - iptw "$ipt" -N "$TETHER_IPT_CHAIN" 2>/dev/null || { - log "ERROR: could not create $ipt chain $TETHER_IPT_CHAIN" - return 1 - } - fi - # De-dupe: remove every existing FORWARD jump, then pin exactly one at #1 - # (netd inserts its own tethering rules into FORWARD, so we must be first). - while iptw "$ipt" -D FORWARD -j "$TETHER_IPT_CHAIN" 2>/dev/null; do :; done - iptw "$ipt" -I FORWARD 1 -j "$TETHER_IPT_CHAIN" 2>/dev/null || { - log "ERROR: could not insert FORWARD -> $TETHER_IPT_CHAIN for $ipt" - return 1 - } -} -fill_chain() { - local ipt="$1" reject="$2" - iptw "$ipt" -F "$TETHER_IPT_CHAIN" 2>/dev/null || return 1 - # Single rule: reject everything that would be forwarded through us. - iptw "$ipt" -A "$TETHER_IPT_CHAIN" -j REJECT --reject-with "$reject" 2>/dev/null || true -} -# Only rebuild when actually tampered (chain missing, unlinked from FORWARD, or -# wrong size). Avoids forking a flush+refill every tick, which pegs netd. -chain_intact() { - local ipt="$1" actual - iptw "$ipt" -C FORWARD -j "$TETHER_IPT_CHAIN" >/dev/null 2>&1 || return 1 - actual="$(iptw "$ipt" -S "$TETHER_IPT_CHAIN" 2>/dev/null | grep -c '^-A')" - [ "$actual" = "1" ] -} -apply_forward_block() { - if command -v iptables >/dev/null 2>&1; then - if chain_intact iptables; then :; \ - elif ensure_chain iptables && fill_chain iptables icmp-port-unreachable; then - log "iptables (v4) FORWARD block rebuilt (was missing/tampered)" - fi - fi - if command -v ip6tables >/dev/null 2>&1; then - if chain_intact ip6tables; then :; \ - elif ensure_chain ip6tables && fill_chain ip6tables icmp6-port-unreachable; then - log "ip6tables (v6) FORWARD block rebuilt (was missing/tampered)" - fi - fi -} -teardown_forward_block() { - local ipt - for ipt in iptables ip6tables; do - command -v "$ipt" >/dev/null 2>&1 || continue - while iptw "$ipt" -D FORWARD -j "$TETHER_IPT_CHAIN" 2>/dev/null; do :; done - iptw "$ipt" -F "$TETHER_IPT_CHAIN" 2>/dev/null || true - iptw "$ipt" -X "$TETHER_IPT_CHAIN" 2>/dev/null || true - done -} # ---- Lever 3: actively stop the softAP (best-effort, WiFi only) ---- diff --git a/phone_focus_mode/tether_iptables.sh b/phone_focus_mode/tether_iptables.sh new file mode 100755 index 00000000..f918200f --- /dev/null +++ b/phone_focus_mode/tether_iptables.sh @@ -0,0 +1,103 @@ +#!/system/bin/sh +# shellcheck shell=ash +# iptables chain management and tethering-offload control for tether_enforcer. +# +# A sibling file rather than a lib/ member: deploy.sh copies a hardcoded +# per-file list into REMOTE_DIR and never deploys lib/, so anything sourced on +# the phone must sit beside the entry script and be listed there too. + +apply_offload_off() { + local cur + cur="$(settings get global "$TETHER_OFFLOAD_KEY" 2>/dev/null)" + if [ "$cur" != "1" ]; then + settings put global "$TETHER_OFFLOAD_KEY" 1 2>/dev/null \ + && log "tether offload was '$cur' - forced disabled (=1)" + fi +} + +snapshot_offload() { + local cur + cur="$(settings get global "$TETHER_OFFLOAD_KEY" 2>/dev/null)" + printf '%s\n' "${cur:-null}" > "$TETHER_OFFLOAD_SNAP" 2>/dev/null || true +} + +restore_offload() { + local snap + [ -f "$TETHER_OFFLOAD_SNAP" ] && snap="$(cat "$TETHER_OFFLOAD_SNAP" 2>/dev/null)" + case "$snap" in + ''|null) + # No pre-existing value: clear ours so the OS default returns. + settings delete global "$TETHER_OFFLOAD_KEY" 2>/dev/null || true + ;; + *) + settings put global "$TETHER_OFFLOAD_KEY" "$snap" 2>/dev/null || true + ;; + esac +} + +# Run iptables/ip6tables with a 2s xtables lock-wait so our calls queue for the +# lock instead of silently failing the instant netd holds it (proven necessary +# on-device for the curfew net layer). $1 = binary. +iptw() { + local bin="$1" + shift + "$bin" -w 2 "$@" +} + +ensure_chain() { + local ipt="$1" + if ! iptw "$ipt" -L "$TETHER_IPT_CHAIN" >/dev/null 2>&1; then + iptw "$ipt" -N "$TETHER_IPT_CHAIN" 2>/dev/null || { + log "ERROR: could not create $ipt chain $TETHER_IPT_CHAIN" + return 1 + } + fi + # De-dupe: remove every existing FORWARD jump, then pin exactly one at #1 + # (netd inserts its own tethering rules into FORWARD, so we must be first). + while iptw "$ipt" -D FORWARD -j "$TETHER_IPT_CHAIN" 2>/dev/null; do :; done + iptw "$ipt" -I FORWARD 1 -j "$TETHER_IPT_CHAIN" 2>/dev/null || { + log "ERROR: could not insert FORWARD -> $TETHER_IPT_CHAIN for $ipt" + return 1 + } +} + +fill_chain() { + local ipt="$1" reject="$2" + iptw "$ipt" -F "$TETHER_IPT_CHAIN" 2>/dev/null || return 1 + # Single rule: reject everything that would be forwarded through us. + iptw "$ipt" -A "$TETHER_IPT_CHAIN" -j REJECT --reject-with "$reject" 2>/dev/null || true +} + +# Only rebuild when actually tampered (chain missing, unlinked from FORWARD, or +# wrong size). Avoids forking a flush+refill every tick, which pegs netd. +chain_intact() { + local ipt="$1" actual + iptw "$ipt" -C FORWARD -j "$TETHER_IPT_CHAIN" >/dev/null 2>&1 || return 1 + actual="$(iptw "$ipt" -S "$TETHER_IPT_CHAIN" 2>/dev/null | grep -c '^-A')" + [ "$actual" = "1" ] +} + +apply_forward_block() { + if command -v iptables >/dev/null 2>&1; then + if chain_intact iptables; then :; \ + elif ensure_chain iptables && fill_chain iptables icmp-port-unreachable; then + log "iptables (v4) FORWARD block rebuilt (was missing/tampered)" + fi + fi + if command -v ip6tables >/dev/null 2>&1; then + if chain_intact ip6tables; then :; \ + elif ensure_chain ip6tables && fill_chain ip6tables icmp6-port-unreachable; then + log "ip6tables (v6) FORWARD block rebuilt (was missing/tampered)" + fi + fi +} + +teardown_forward_block() { + local ipt + for ipt in iptables ip6tables; do + command -v "$ipt" >/dev/null 2>&1 || continue + while iptw "$ipt" -D FORWARD -j "$TETHER_IPT_CHAIN" 2>/dev/null; do :; done + iptw "$ipt" -F "$TETHER_IPT_CHAIN" 2>/dev/null || true + iptw "$ipt" -X "$TETHER_IPT_CHAIN" 2>/dev/null || true + done +}