"""Pure block-selection logic (AGENTS.md FR-3, FR-4, FR-16). No psutil and no Windows here, so the decision can be unit tested anywhere. :mod:`webrestrict.process_watch` supplies the running process list and does the killing; this module only decides **which** processes are blocked. The rules are deliberately small, because the client must never widen or narrow them on its own (AGENTS.md ยง10 rule 7): * nothing is blocked while the config is not effectively locked; * a process is blocked when its normalised name is on the blocklist; * the allowlist always wins (FR-4); * the client never kills itself, its parent chain, or a protected system process, whatever the blocklist says. """ from __future__ import annotations from dataclasses import dataclass from datetime import datetime from typing import Iterable from .config import DeviceConfig, bare @dataclass(frozen=True) class Victim: """One process that must be terminated.""" pid: int name: str # as reported by the OS, e.g. "notepad.exe" matched: str # the normalised blocklist entry that matched def select_victims( processes: Iterable[tuple[int, str]], config: DeviceConfig, *, now: datetime | None = None, protected_names: Iterable[str] = (), protected_pids: Iterable[int] = (), ) -> list[Victim]: """Choose the processes to terminate for one enforcement tick. ``processes`` is an iterable of ``(pid, name)`` pairs, so the caller can pass a psutil snapshot, a fake list in a test, or a single process. The result is ordered by pid and contains no duplicates, so the caller can log it. """ if not config.is_blocking(now): return [] blocked = config.blocked_names() if not blocked: return [] allowed = config.allowed_names() protected = {bare(item) for item in protected_names if item and str(item).strip()} do_not_touch = {int(pid) for pid in protected_pids} victims: list[Victim] = [] seen: set[int] = set() for pid, name in processes: try: pid = int(pid) except (TypeError, ValueError): continue value = bare(name) if not value or pid in seen: continue seen.add(pid) if pid in do_not_touch or value in protected or value in allowed: continue if value in blocked: victims.append(Victim(pid=pid, name=name, matched=value)) victims.sort(key=lambda victim: (victim.matched, victim.pid)) return victims def self_protection(pid: int, image_names: Iterable[str], parents: Iterable[int]) -> tuple[set[int], set[str]]: """The pids and names the enforcer must never touch for its own process tree. The client runs as ``python.exe`` on the kid's machine, so a blocklist that named Python would otherwise make the client kill itself and stop reporting. """ pids = {int(pid)} | {int(parent) for parent in parents} names = {str(name) for name in image_names if str(name).strip()} return pids, names