Skip to content

v0.5.4

Choose a tag to compare

@aaronsb aaronsb released this 02 Sep 23:34
· 17 commits to main since this release
v0.5.4
eaf62dd

Fixed

  • Strict mode admitted unreviewed discoveries — the mode advertises "whitelist only", but the poll loop only reached the warn-then-kill path when a process matched no pattern at all. A process matching a pattern in discovered state — a row discovery created automatically the first time it crossed the CPU threshold — ran unchallenged. On a host that had been in normal mode for a while, that covered most of what a user actually launches, so switching to strict changed nothing for them. Admission is now an explicit allowlist of active and ignored
  • discover disallow on a browser domain did nothing — the CLI wrote disallowed to the row and printed "will be terminated on detection", but the kill path operates on PIDs and browser domains are handled in a separate block that only records runtime. A domain could sit disallowed indefinitely and remain reachable. ADR-001 listed this as a stretch goal; it was never built

Added

  • Browser managed-policy generation (ADR-003) — playtimed writes Chrome-family and Firefox policy files from its own browser_domain patterns, so the browser enforces at navigation time across every tab and window rather than playtimed reacting to whichever tab has focus. normal mode generates a blocklist from disallowed domains; strict generates an allowlist of permitted domains with everything else blocked; passthrough withdraws playtimed's rules
  • playtimed browser-policy shows what would be written, --sync writes it. Generation also runs at daemon startup, on SIGHUP, and after any CLI command that changes a domain's state or the daemon mode
  • For Chrome-family browsers playtimed writes exactly one file, playtimed.json, and never reads or overwrites others in the merged policy directory. Firefox has no such directory, so there playtimed owns the policies.WebsiteFilter key inside the shared policies.json and leaves the rest of the document intact
  • An active gaming-category domain is written into the blocklist rather than the allowlist. active means "counted against the limit", and playtimed cannot close a tab when a domain's budget expires — so on a host that had been tracking YouTube time, reading active as "permitted" would have generated an allowlist of exactly the sites a lockdown exists to block

Changed

  • ActivityDB.get_patterns() now returns only pattern_type = 'process' rows by default. A browser_domain pattern holds a hostname, but process matching is an unanchored regex search over process name and command line, so a domain row in that scan meant any process whose cmdline contained the string was treated as that pattern — and a disallowed one was killed. Pass pattern_type=None for every type
  • get_browser_patterns() now honours the enabled column, which it previously ignored
  • The systemd unit grants write access to each browser's configuration root under /etc. ProtectSystem=strict made all of /etc read-only, so daemon-side policy writes would have failed EROFS and been logged as a warning while the feature appeared to work

Security

  • Browser database copies used tempfile.mktemp() — the workers copy Chrome's History and Firefox's places.sqlite somewhere unlocked before reading them, and mktemp returns a path without creating it. The daemon runs as root, the source is a file the monitored user owns and controls the contents of, and /tmp is world-writable, so that user could plant a symlink at the predicted path and have root write bytes of their choosing to a path of their choosing. PrivateTmp=true in the unit blocks it for the packaged daemon; playtimed run started by hand is not covered. Both workers now call one helper in browser/base.py, built on mkstemp, which creates the file itself with mode 0600. The helper removes the file if the copy raises: the caller has no name for it until the helper returns, so nothing else can — and a directory left at the profile path satisfies the caller's .exists() guard, making the copy fail on every poll
  • The content goes through the descriptor mkstemp returned rather than being written back by path. A write by name follows a symlink at the destination and carries no O_EXCL, so reopening would have reopened the same question, leaving only /tmp's sticky bit between root and a user-directed write
  • The copy is a descriptor write rather than shutil.copy2. Browsers leave those profile files world-readable, and copy2 would preserve the source mode — widening the 0600 back out and exposing the user's browsing history to every local account for as long as the copy exists

Changed

  • make check passes. It never had: ruff's default rule selection grows between minor releases, so the gate went red on code no commit touched, reaching 244 findings. ruff is now pinned in the dev extra, three rule groups are switched off with the reason recorded beside each (naive datetimes, which are deliberate for a daemon whose budgets are per local calendar day; blind excepts, which are all log-and-continue probes; and the iteration idiom for entries that vanish mid-scan), and the rest are fixed
  • playtimed message list prints the urgency of each template. The colour was computed and discarded

Note

Chrome reloads managed policy on launch and periodically thereafter, not immediately. A domain disallowed while the browser is open stays reachable until it reloads — the process layer is instantaneous and the browser layer is not.