From 2cd62587d6d3165342fd3d9674b2391898354979 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Mon, 17 Aug 2026 14:38:53 +0200 Subject: [PATCH] tether_enforcer: split 267 -> 182 + 103, and grow both deploy lists First phone_focus_mode split, so it sets the pattern. The nine iptables and tethering-offload functions move to 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 has to sit beside the entry script. deploy.sh has two hardcoded lists, not one - an adb push into the staging dir and an adb_root cp out of it. 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 its scratch dir, so it was taught to copy the sibling too. That is the same coupling as the phone deployment, in a form that can actually be run - and running it is the evidence: 17 passed, 0 failed, identical to the pre-split baseline, with the daemon sourcing the new sibling exactly as it will on the device. This script is #!/system/bin/sh with no `set -e`, so the extractor refused to guess an anchor rather than placing the source line somewhere wrong. It went after the existing config.sh source by hand. All 20 functions verbatim. Not verified on the phone - the next deploy is where that gets confirmed. Over-cap files 25 -> 24. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY --- .../tether-enforcer-split-2026-08-17.json | 17 +++ .../tether-enforcer-split-2026-08-17.json | 56 ++++++++++ phone_focus_mode/deploy.sh | 2 + .../lib/tests/test_tether_enforcer.sh | 3 + phone_focus_mode/tether_enforcer.sh | 89 +-------------- phone_focus_mode/tether_iptables.sh | 103 ++++++++++++++++++ 6 files changed, 183 insertions(+), 87 deletions(-) create mode 100644 docs/superpowers/contracts/tether-enforcer-split-2026-08-17.json create mode 100644 docs/superpowers/evidence/tether-enforcer-split-2026-08-17.json create mode 100755 phone_focus_mode/tether_iptables.sh 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 +}