Skip to content

Security: vindfjur/ephemdir

Security

SECURITY.md

Security Policy

Supported Versions

The supported release line is 0.8.x (with 0.7.x still receiving security fixes). Supported runtimes are Python 3.10+ on Linux and macOS. Windows is not supported until a handle-bound recursive deletion backend is available.

Reporting a Vulnerability

Please report suspected vulnerabilities privately to the maintainer before opening a public issue. Include:

  • affected ephemdir version and operating system;
  • the exact command/API call used;
  • the directory layout, permissions and mount/symlink setup;
  • whether the parent directory is private, sticky shared, or non-sticky shared;
  • a minimal reproducer when possible.

For deletion-safety reports, preserve the registry file and any .deleting staging directory for inspection. Do not run recover --forget until the issue has been triaged.

Security Model

ephemdir only deletes directories it can verify by marker and inode identity. POSIX deletion is fd-relative and refuses symlink, parent-trust and mount-boundary violations. The owned staging tree's contents are removed through opened directory descriptors; only the final removal of the now-empty staging directory is by pathname, because POSIX offers no fd-only rmdir. That last step carries a small, accepted same-user race: a racing same-user process could swap an empty replacement into the staging path between the identity check and the rmdir. The window is bounded — rmdir refuses a non-empty directory, a symlink or a mountpoint, so any replacement holding data survives, and only an empty directory the racer itself created could be removed (not a privilege-boundary break). When a platform cannot provide the required safe primitive, tempdir() fails before creation and cleanup fails closed before claim: the original pathname and active entry stay untouched rather than being moved or deleted by pathname. A missing active path is not treated as proof that cleanup succeeded: the entry remains tracked and blocked until ephemdir verifies a deletion, or until the user explicitly forgets it with prune, keep or recover --forget for recovery entries. During recursive deletion the ownership marker is preserved until every payload entry is gone and a durable delete_phase=root-only journal update succeeds. A crash after that point may leave an empty markerless staging root; recovery removes only that verified empty root and refuses it if any unexpected entry appeared. Registry reads and writes are bounded to 1 MiB, use a versioned envelope (schema v3, which adds optional per-directory tags and description) on write, and use non-blocking no-follow opens, preventing FIFOs and other special files from stalling commands or the scheduled sweeper. An older on-disk schema is read unchanged and upgraded to the current one the next time a command modifies the registry, after copying the old file to an owner-only backup beside it (for example registry.json.v2.bak); an existing backup is never overwritten, and a registry written by a newer ephemdir is refused rather than rewritten. A registry that is group/world-writable is treated as potentially tampered: it is refused outright (not parsed, swept, emptied or quarantined) and left in place for manual inspection. A merely world-/group-readable registry is tightened to 0600 by the next command that writes it; a read-only command (list, tree, path, last, explain) deliberately does not mutate it and instead logs a warning, and doctor reports it as a finding. Because the registry now stores tags/description, keeping it owner-only also keeps those labels private. Any malformed individual registry entry makes the whole registry invalid for that command; transactions copy the inspected bytes to a unique registry.json.corrupt-* file, leave the active registry path in place as a blocking object and abort instead of saving a filtered or empty state.

install-service trust boundary

install-service schedules your Python interpreter to run ephemdir sweep unattended, later, as your user. Before writing any unit/plist it verifies that the interpreter, the entire ephemdir package tree (rejecting symlinked package subdirectories) and the interpreter-startup hooks (.pth files, sitecustomize, pyvenv.cfg, and tomli on Python 3.10) are owned by you or root. A world-writable component, a foreign-owned component, a symlinked package subdirectory, and any group/world-writable executable file or startup hook are always rejected, under every policy. It also pins the verified effective EPHEMDIR_DATA_DIR and EPHEMDIR_CONFIG_DIR into the installed launchd/systemd definition so the scheduled sweep does not drift to a different registry after logout/login or shell environment changes. Those locations must be reboot-persistent: service installation refuses macOS per-user temporary roots, common volatile runtime roots, and any location nested inside a marked ephemdir tree. The service establishes a valid registry.json at install time and sets fail-closed environment guards; if either the state root or that registry file later vanishes, the sweep aborts instead of creating fresh empty state and orphaning still-existing managed directories.

Files ephemdir installs (service definitions and shell completions) are recorded with their exact SHA-256 in an owner-only manifest. Uninstalling never unlinks by pathname alone: the target is re-opened without following symlinks, must still be a regular owner-owned file that is not group/other writable, its bytes must match the recorded digest, and the unlink happens relative to the verified directory descriptor after an identity recheck. A modified, foreign or unrecorded file is refused with instructions rather than deleted. The manifest itself and its permanent lock must be regular, owner-owned and owner-only; all read-modify-write updates run under the OS lock, so concurrent installers cannot silently lose tracking records. Status treats unsafe parent/file permissions and registry symlinks as unsafe rather than reporting them as installed/present.

On macOS, new entries record the kernel boot-session UUID. Existing registry entries without that field continue to use the stored high-resolution boot time, with a narrow comparison tolerance, so upgrades preserve restart cleanup and a fast reboot is not folded into the previous session.

What it does not do is recursively vet every module those hooks or runtime dependencies may import: a .pth line may run an arbitrary import, and validating the full transitive import closure is equivalent to trusting the whole environment. This is the same surface as running any other Python program from that interpreter. Therefore the scheduled service must be installed only from a Python environment whose site-packages cannot be modified by other local users — which a normal per-user pip/venv installation already guarantees. On a single-user system this requires no action; on a shared host, confirm the environment's ownership and permissions before scheduling sweeps. One-off interactive tempdir(), ephemdir sweep and the rest of the CLI do not rely on this and are unaffected.

Runtime-trust policy: strict vs balanced

The one place the policy is configurable is how a group-writable directory ancestor of the runtime is treated. This is governed by --runtime-policy strict|balanced (or EPHEMDIR_SERVICE_RUNTIME_POLICY).

  • strict rejects any group-writable component. It is the default on every platform except macOS and is the correct choice on a genuinely shared multi-user host.
  • balanced (the default on macOS) allows a group-writable directory ancestor only as a narrow, property-checked Homebrew/usr-local carve-out, with a warning. All of the following must hold, or the component is rejected: the platform is macOS; the resolved path is under /opt/homebrew or /usr/local; the directory is owned by root or the installing user; it is not world-writable; and its owning group is a local administrator group (admin, gid 80). This reflects the single-user model: the admin group on a personal Mac is the owner, not an attacker. It exists because a stock Homebrew interpreter lives under /opt/homebrew/Cellar (mode 0775, group admin); under strict that ancestor is refused, which silently prevents the scheduled sweep from ever being installed — and therefore prevents reboot/expiry cleanup from running automatically.

The owning-group check is the load-bearing restriction: POSIX write permission on a directory lets any member of its group replace entries inside it, so a group-writable directory on the import path whose group could contain a different unprivileged user would be a code-execution vector for the scheduled service. Restricting the carve-out to the local admin group (plus the prefix allowlist) keeps the relaxation within the threat model. A group-writable directory owned by an ordinary shared group is rejected even under balanced.

balanced relaxes only group-writable directory ancestors that pass that carve-out. World-writable components, foreign-owned components, symlinked package subdirectories, and group/world-writable executable files or startup hooks remain hard failures under both policies. If you prefer strict everywhere, install the service from a private uv-managed venv under your home directory, whose components are owned only by you.

This threat model deliberately excludes root, the local administrator, and other members of the owner's own admin group on a personal machine; it defends against a different unprivileged local user, not against the owner.

There aren't any published security advisories