From 2a2a8087783a9e011a62891c83e62405c28d8ba0 Mon Sep 17 00:00:00 2001 From: operator Date: Wed, 7 Oct 2026 21:09:45 +0000 Subject: [PATCH] fix(watchers): exempt runaway shells from protection to prevent host OOM - update is_protected() in box-stability-watcher.py to revoke immunity from bash/zsh processes with RSS >= 2048MB - update watchers/README.md to document the 2048MB interactive shell threshold - add unit tests verifying shell protection vs runaway exemption in test_box_stability_watcher.py --- tests/test_box_stability_watcher.py | 3 +++ watchers/README.md | 6 ++++-- watchers/box-stability-watcher.py | 8 ++++++-- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/tests/test_box_stability_watcher.py b/tests/test_box_stability_watcher.py index 02c5696..5f7dc51 100755 --- a/tests/test_box_stability_watcher.py +++ b/tests/test_box_stability_watcher.py @@ -37,6 +37,9 @@ class TestConfigAndSafety(unittest.TestCase): self.assertTrue(w.is_protected(777, "tailscaled", "/usr/sbin/tailscaled", cfg)) self.assertFalse(w.is_protected(1234, "muse-bin", "/home/super/.local/bin/muse-bin-1.4.3 resume abc", cfg)) self.assertFalse(w.is_protected(5678, "chromium", "/usr/lib/chromium/chromium --type=renderer", cfg)) + # Shell protection & runaway exemption + self.assertTrue(w.is_protected(9999, "bash", "/bin/bash", {"protected_commands": ["bash"]}, rss_mb=50)) + self.assertFalse(w.is_protected(9999, "bash", "bash test_script.sh", {"protected_commands": ["bash"]}, rss_mb=2500)) class TestStabilityEvaluation(unittest.TestCase): diff --git a/watchers/README.md b/watchers/README.md index 6105eca..4e2b3d7 100644 --- a/watchers/README.md +++ b/watchers/README.md @@ -37,8 +37,10 @@ When a process is paused: ## Protected Whitelist -The watcher will **never** terminate or renice: -`sshd`, `tailscaled`, `tailscale`, `systemd`, `dbus-broker`, `pipewire`, `wireplumber`, `tmux` (main server), `bash`, `zsh`, `ghostty`, `alacritty`. +The watcher will **never** terminate or renice core system services or interactive terminal sessions: +`sshd`, `tailscaled`, `tailscale`, `systemd`, `dbus-broker`, `pipewire`, `wireplumber`, `tmux` (main server), `ghostty`, `alacritty`. + +Interactive shells (`bash`, `zsh`, `sh`) are protected while operating within normal memory bounds (<2048MB RSS). Runaway scripts or test jobs executing under `bash`/`zsh` that exceed 2048MB RSS automatically lose whitelist immunity and are subjected to the standard ORANGE pause/cull lifecycle to protect the host against OOM crashes. ## Unified Box CLI Integration diff --git a/watchers/box-stability-watcher.py b/watchers/box-stability-watcher.py index 2b271ab..1fe12b8 100755 --- a/watchers/box-stability-watcher.py +++ b/watchers/box-stability-watcher.py @@ -227,11 +227,15 @@ def get_system_metrics() -> Dict[str, Any]: } -def is_protected(pid: int, name: str, cmdline: str, config: Dict[str, Any]) -> bool: +def is_protected(pid: int, name: str, cmdline: str, config: Dict[str, Any], rss_mb: int = 0) -> bool: if pid in (os.getpid(), os.getppid(), 1): return True low_name = name.lower() low_cmd = cmdline.lower() + # Shells (bash/zsh) are only protected while of reasonable memory size. + # A shell consuming >= 2048 MB RSS is a runaway script/test or memory leak, not an interactive shell. + if low_name in ("bash", "zsh", "sh") and rss_mb >= 2048: + return False for prot in config.get("protected_commands", []): p_low = prot.lower() if p_low == low_name or p_low in low_cmd.split(): @@ -289,7 +293,7 @@ def inspect_processes(config: Dict[str, Any]) -> List[Dict[str, Any]]: "nice": nice, "matches_target": matches_target, "tmux_sock": tmux_sock, - "is_protected": is_protected(pid, name, cmdline, config), + "is_protected": is_protected(pid, name, cmdline, config, rss_mb=rss_mb), }) except (psutil.NoSuchProcess, psutil.AccessDenied): continue