feat(keylog): PR_SET_PTRACER_ANY opt-in β narrower alternative to #334 (no host-wide ptrace_scope=0) - #343
Merged
Merged
Conversation
β¦attaches under ptrace_scope=1 Narrower alternative to #334. Instead of persisting kernel.yama.ptrace_scope=0 host-wide and permanently (which exposes EVERY same-UID process forever), keylog.py opts ITSELF in to being traced via prctl(PR_SET_PTRACER, PR_SET_PTRACER_ANY). Yama scope=1 honours this per-tracee opt-in, so the sibling keylog-spin-capture watcher (py-spy) can attach with ptrace_scope left at its hardened default of 1 β exposing exactly one process, only while it runs, no root, no /etc/nixos edit, no reboot-survival question, no fleet-wide hardening loss. - keylog.py: _allow_any_ptracer() early in main(). Gated on KEYLOG_ALLOW_ANY_PTRACER (set by home-manager only on the spin-capture host) and fail-soft β a non-Linux or pre-PR_SET_PTRACER kernel, missing libc, or EINVAL logs and continues, never taking the collector down. - home.nix: set KEYLOG_ALLOW_ANY_PTRACER=1 on keylog.service, gated on enableKeylogSpinCapture (workbench-only), so a host not running the capture never opens its keystroke collector to same-UID tracers. Stale "laptop reads 1 so py-spy can never attach" comments corrected. - keylog-spin-capture.sh: the hard `scope != 0 -> exit` gate (what makes the watcher inert on the current scope=1 fleet) relaxed to bail only on scope >= 2, which the opt-in cannot satisfy. - test_ptrace_optin.py: 6 regression tests (constant values, env-gate spellings, fail-soft). RED on origin/main (AttributeError, function absent), GREEN after. Trade-off, stated straight: PR_SET_PTRACER_ANY opens keylog β a keystroke collector, the most sensitive single process on the box β to ANY same-UID tracer for its lifetime. A specific tracer PID isn't pinnable (the watcher is a oneshot with a changing PID). Mitigating fact, not an excuse: a same-UID attacker can already read keylog's on-disk spool, so this crosses no new trust boundary. Verified on a live scope=1 kernel: sibling process_vm_readv AND PTRACE_ATTACH both fail (-1 EPERM) against a tracee that did NOT opt in, and both succeed (read the canary; rc=0 ATTACHED) against one that did. End-to-end: py-spy dumps both plain and --native frames against a stand-in that called the real opt-in, denied against one that didn't β ptrace_scope left at 1 throughout. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
β¦st gate Address five audit findings on the PR_SET_PTRACER_ANY opt-in. Honesty/scoping fixes β the mechanism is unchanged. 1. Rewrite the false "you could already read the on-disk spool" equivalence in _allow_any_ptracer's docstring. The spool is shipped+unlinked within ~10s (ACTIVITY_FLUSH_SECONDS); live memory holds what it never does β the unflushed Chunker._buf (typed-right-now secrets + backspaced chars that never reach the spool), aborted espanso terms, raw keycodes, and the X11 MIT-MAGIC-COOKIE from the two live Display() connections. Frame it straight: removes a restart/detectability speed bump AND widens the loot; PTRACE_ATTACH is a silent write primitive, not a spool read. 2. Actually clear the opt-in. _revoke_any_ptracer (prctl(PR_SET_PTRACER, 0)) + _maybe_revoke_ptracer, called ~every 60s from the idle loop: once the spin-capture watcher is done (.captured/.giveup), revoke β cutting exposure from keylog's whole lifetime (Restart=always) to "until first capture". Live-proven: a sibling that ATTACHED under scope=1 after opt-in gets EPERM after the clear. 3. Gate enableKeylogSpinCapture on serverMode (an explicit operator marker), not !isLaptop (a fail-OPEN display/backlight heuristic that would silently hand PR_SET_PTRACER_ANY to any future non-intel-backlight graphical host). isLaptop's display meaning is untouched. (Standalone HM config, both hosts report hostname "nixos", so an impure operator marker is the only real host allowlist available.) 4. Kill two surviving mutants: test_main_invokes_the_optin pins that main() actually calls the helper (M4 β the isolation seam); test_nonzero_rc_logs_errno pins that the rc!=0 branch is observable (M7). Plus revoke-path coverage. 5. Correct three false facts: drop the fabricated "fleet default since the 2026-08-04 reboot" (scope=1 is Yama's unmanaged upstream default; laptop booted 07-31) in keylog.py + keylog-spin-capture.sh, and retract the known-false "this host already reads 0 / a py-spy attach succeeded" claim in apply-perf-tuning-2026-07-30.sh. Also: keylog-spin-capture.sh scope precondition now matches only literal 0/1 and bails on anything else (fail CLOSED) instead of [[ garbage -ge 2 ]] proceeding. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Narrower alternative to #334. Do not merge alongside it β these are two ways to solve the same problem; pick one.
The problem (same premise as #334)
keylog-spin-captureuses py-spy to dumpkeylog.service, a siblingsystemd --userunit β never a descendant of the capture. Yamakernel.yama.ptrace_scope=1(the fleet default) blocks bothPTRACE_ATTACHandprocess_vm_readvfor a non-descendant even at the same UID, so the watcher hits EPERM and is inert. Live workbench reads1.What #334 does vs. what this does
#334 persists
ptrace_scope=0system-wide and permanently β exposing every same-UID process (browser sessions, kubeconfigs in a running tool, ssh-agent) to memory-read, forever, and needs a root/etc/nixosedit + asystemd-sysctlrestart.This uses the strictly smaller mechanism #334 never considers: the tracee opts itself in via
prctl(PR_SET_PTRACER, PR_SET_PTRACER_ANY). Yama scope=1 explicitly honours this. Result:ptrace_scopestays at its hardened1, and exposure is exactly one process, only while it runs β no root, no/etc/nixosedit, no reboot-survival question, no fleet-wide hardening loss.Changes
keylog.pyβ_allow_any_ptracer()runs first thing inmain(). Constants verified against<linux/prctl.h>:PR_SET_PTRACER=0x59616d61,PR_SET_PTRACER_ANY=(unsigned long)-1. Gated onKEYLOG_ALLOW_ANY_PTRACER; fail-soft (non-Linux / old kernel / missing libc / EINVAL logs and continues β never crashes the collector).home.nixβ setsKEYLOG_ALLOW_ANY_PTRACER=1onkeylog.service, gated onenableKeylogSpinCapture(workbench-only). So a host not running the capture never opts its keystroke collector in. Stale "laptop reads 1 β py-spy can never attach" comments corrected (that rationale no longer holds; workbench-only is now purely a py-spy build-cost decision).keylog-spin-capture.shβ the hardscope != 0 β exitgate (what makes the watcher inert today) relaxed to bail only onscope >= 2(which needs CAP_SYS_PTRACE the opt-in can't grant).tests/test_ptrace_optin.pyβ 6 regression tests. RED onorigin/main(AttributeError, function absent) β GREEN after.Trade-off, stated straight β not sold as free
PR_SET_PTRACER_ANYopens keylog β a keystroke collector, arguably the single most sensitive process on the box β to any same-UID tracer for its whole lifetime. Pinning one tracer PID isn't practical: the watcher is aoneshotwhose PID changes every 5-minute tick. The mitigating fact (a boundary statement, not an excuse): a same-UID attacker can already read keylog's on-disk spool at~/.local/state/activity/spool, so live-memory read does not cross a new trust boundary β it's the same same-UID boundary in a different shape. That, plus the env-gate keeping it workbench-only, is why the blast radius is far smaller than #334's.Verification (all with
ptrace_scopeleft at 1, on a live scope=1 kernel)Rig β sibling (non-descendant, same-UID) tracer, negative control in each arm:
process_vm_readvPTRACE_ATTACH-1 EPERM-1 EPERMPR_SET_PTRACER_ANYn=29, read the canaryCANARY-9f3a2b6c-deadbeef-rigrc=0 ATTACHEDEnd-to-end with the real consumer β py-spy against a stand-in that called the real
_allow_any_ptracer:dump(plain) and--nativeboth return full frames.Permission Denied.Regression matrix:
test_ptrace_optin.pyβ 6 failed atorigin/main, 6 passed at HEAD. Full keylog suite 67 passed.Note on the
ptrace_scope=0history (#334 vs the perf script)#334 attributes the earlier workbench
0to a manualecho 0 | sudo tee;apply-perf-tuning-2026-07-30.sh:55-58asserts the host "already reads 0 (nothing in /etc/sysctl.d sets it)". Discriminating signals: (a) the flake.lock nixpkgs pin last changed 2026-07-29 and is unchanged across the 2026-08-04 reboot β no bump in the window; (b) on a live host from this pinned nixpkgs, nothing (/etc/sysctl.d, systemd50-default.conf, NixOS60-nixos.conf) setsptrace_scope, so the value is the kernel compile-time default; (c)CONFIG_SECURITY_YAMA=ywith no override β mainline defaultYAMA_SCOPE_RELATIONAL=1. So the default was1both before and after; a reading of0had to come from a transient override. This supports #334's manual-echo explanation and refutes the perf script's "reads 0 as default" claim β the fleet's hardening posture did not silently flip via a nixpkgs default change. (Caveat: measured on a representative host from the same pin, not the workbench's own generation history, which I did not disturb.)π€ Generated with Claude Code