Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,58 @@ All notable changes to this project are documented here. The format is based on
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.6.0] - 2026-06-27

### Added
- Current-directory target resolution from the `.ephemdir` marker. `keep`,
`rm`, `explain` and `extend` can now operate on the ephemdir directory you are
standing in (or any subdirectory of it) without an explicit name or path; the
target is always the managed root. They act only when the nearest `.ephemdir`
marker matches an active tracked entry by marker id and inode, and otherwise
fail closed rather than guessing.
- `ephemdir extend` accepts a bare lifetime when run inside a tracked directory:
`ephemdir extend 30m` and `ephemdir extend --forever` extend the current
directory, while `ephemdir extend <name> 30m` keeps working as before.
- `ephemdir path` now prefers the current ephemdir directory when run from
inside one, preserving the most-recently-created fallback when run from
outside. A present-but-invalid marker makes `path` fail closed instead of
falling back.

### Changed
- `install-service` now has a runtime-trust policy, selectable with
`--runtime-policy strict|balanced` (or the `EPHEMDIR_SERVICE_RUNTIME_POLICY`
environment variable). `balanced` is the default on macOS and `strict` the
default on Linux. `balanced` allows a group-writable directory ancestor of
the service runtime only as a narrow Homebrew/usr-local carve-out — macOS, a
path under `/opt/homebrew` or `/usr/local`, owned by root or you, not
world-writable, and whose owning group is a local administrator group
(`admin`) — emitting a warning. This lets a stock Homebrew interpreter host
the scheduled sweep, while a group-writable directory owned by an ordinary
shared group (which could contain another local user) is still rejected.
World-writable components, foreign-owned components, symlinked package
subdirectories, and group/world-writable executable files or
interpreter-startup hooks remain hard failures under both policies.

### Fixed
- Directories were never removed after a reboot. The ownership identity check
compared the stored device number (`st_dev`) as well as the inode, but a
device number is not stable across reboots (macOS reassigns it for an APFS
volume at every boot, and it is not guaranteed stable on Linux). After a
restart every tracked directory therefore looked like a replacement and was
left untouched, defeating restart and expiry cleanup. The identity check now
compares the inode number only; the random ownership marker remains the
primary proof, and a directory replaced at the same path still gets a new
inode and is still detected. Within-operation safety checks (fd-relative
delete, mount-boundary detection) are unchanged and continue to use the full
device+inode pair.
- The scheduled sweep service could not be installed from a default Homebrew
Python on macOS, because the previous, unconditional rejection of any
group-writable runtime component refused `/opt/homebrew/Cellar`. Without an
installed service, periodic and post-reboot cleanup never ran automatically.
The new default `balanced` policy installs successfully on a stock Homebrew
setup while keeping the strict behaviour available. (Together with the inode
fix above, this is what restores automatic post-reboot cleanup on macOS.)

## [0.5.0] - 2026-06-18

### Added
Expand Down
90 changes: 72 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -232,25 +232,46 @@ ephemdir uninstall-service

`install-service` validates the persistent runtime before writing anything:
every component of the interpreter and package paths must be owned by you
(or root) and must not be writable by other users. This deliberately rejects
**any** group/world-writable component — including sticky directories like
`/tmp` — so a virtualenv created under `/tmp` cannot host the scheduled
service even though it is fine for one-off interactive use. Install the venv
under your home directory instead. It also checks the interpreter-startup
hooks Python runs before ephemdir is imported (`.pth` files in site-packages,
`sitecustomize`, `pyvenv.cfg`, and the `tomli` package on Python 3.10).

On macOS, `install-service` may reject a Homebrew or otherwise shared Python
runtime if any interpreter/package component is group/world-writable. That is
expected: launchd will run the interpreter later, so ephemdir refuses a runtime
another local user could modify after installation. For a dedicated service
runtime, use a private uv-managed virtual environment under your home directory:
(or root). It always rejects a **world-writable** component (including sticky
directories like `/tmp`, so a virtualenv under `/tmp` cannot host the scheduled
service), a **foreign-owned** component, a symlinked package subdirectory, and
any group/world-writable **executable** file or interpreter-startup hook
(`.pth` files in site-packages, `sitecustomize`, `pyvenv.cfg`, and the `tomli`
package on Python 3.10).

How strictly it treats a merely **group-writable directory ancestor** is set by
the runtime policy:

```bash
ephemdir install-service --interval 600 # default
ephemdir install-service --interval 600 --runtime-policy strict
ephemdir install-service --interval 600 --runtime-policy balanced
```

* **`balanced`** (the default on macOS) allows a group-writable directory
ancestor **only** as a narrow Homebrew/usr-local carve-out, printing a
warning. All of these must hold: macOS; the resolved path is under
`/opt/homebrew` or `/usr/local`; the directory is owned by root or you; it is
not world-writable; and its **owning group is a local administrator group**
(`admin`). This is exactly what a stock Homebrew interpreter needs — its
`/opt/homebrew/Cellar` ancestor is mode `0775`, group `admin` (the machine's
administrators, i.e. the owner on a personal Mac) — which `strict` rejects,
silently preventing the scheduled sweep from ever being installed. A
group-writable directory whose owning group is an ordinary shared group (which
could contain another unprivileged user) is **not** covered and is rejected.
* **`strict`** (the default off macOS) rejects **any** group-writable component.
Use it on a genuinely shared multi-user host.

The default is also overridable with `EPHEMDIR_SERVICE_RUNTIME_POLICY=strict|balanced`.
If you would rather keep `strict` everywhere, install into a private uv-managed
virtual environment under your home directory, whose components are owned only
by you:

```bash
uv python install 3.12
uv venv ~/.venvs/ephemdir-safe --python 3.12
uv pip install --python ~/.venvs/ephemdir-safe/bin/python ephemdir
~/.venvs/ephemdir-safe/bin/python -I -m ephemdir install-service
~/.venvs/ephemdir-safe/bin/python -I -m ephemdir install-service --runtime-policy strict
```

> **Trust boundary for `install-service`.** The scheduled job runs your Python
Expand All @@ -264,12 +285,23 @@ uv pip install --python ~/.venvs/ephemdir-safe/bin/python ephemdir
> do; on a shared multi-user host, ensure the environment is owned by you and
> not group/world-writable before scheduling sweeps.

Prefer to wire it up yourself? The equivalents are:

* **Linux (cron):** `*/10 * * * * ephemdir sweep`
* **macOS (launchd):** a `LaunchAgent` running `ephemdir sweep`; template in
Prefer to wire it up yourself? These are **manual alternatives, not equivalents** —
they are less hardened than `install-service` unless you reproduce all of its
properties: run the validated interpreter as `python -I -m ephemdir sweep` (not
a bare `ephemdir` from `PATH`), with working directory `/`, a fixed trusted
`PATH`, and pinned `EPHEMDIR_DATA_DIR` / `EPHEMDIR_CONFIG_DIR`. `install-service`
additionally validates the runtime and verifies the isolated import before
writing anything; a hand-written job does none of that.

* **Linux (cron):** `*/10 * * * * /path/to/validated/python -I -m ephemdir sweep`
* **macOS (launchd):** a `LaunchAgent` running the same
`/path/to/validated/python -I -m ephemdir sweep`; template in
[`packaging/`](packaging/).

Set `EPHEMDIR_DATA_DIR`/`EPHEMDIR_CONFIG_DIR` in the job's environment so the
scheduled sweep cannot drift to a different registry. When in doubt, prefer
`install-service`.

You can also keep a foreground watcher running:

```bash
Expand Down Expand Up @@ -324,6 +356,28 @@ unique prefix (`bra` or `brave-otter` for `brave-otter-a81f42c9d047315b`).
Add `-v` for more output or `-q` to
stay quiet.

### Managing the current directory

If you are inside a directory ephemdir created (or any subdirectory of it), the
`path`, `explain`, `extend`, `keep` and `rm` commands work with no name:

```bash
cd "$(ephemdir new --lifetime 2h)"
ephemdir explain # describe the current ephemdir directory
ephemdir extend 30m # extend the current directory (or --forever)
ephemdir keep # keep the current directory; stop tracking it
ephemdir rm # delete the current directory's managed root
```

ephemdir looks for the nearest `.ephemdir` marker at or above your working
directory and applies the command only when that marker matches an active
tracked entry. From a subdirectory the target is always the managed **root**,
not the subdirectory. If the marker is missing, altered or does not match an
active entry, the command refuses to guess and exits with an error rather than
falling back to another directory. Outside any tracked directory these commands
report that there is no target; only `ephemdir path` keeps its old fallback to
the most recently created directory.

### Listing with time left

`ephemdir list` shows each directory's status at a glance:
Expand Down
55 changes: 46 additions & 9 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@

## Supported Versions

The supported release lines are `0.4.x` and `0.5.x`. Supported runtimes are
Python 3.10+ on Linux and macOS. Windows is not supported until a handle-bound
recursive deletion backend is available.
The supported release line is `0.6.x` (with `0.5.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

Expand Down Expand Up @@ -49,7 +49,9 @@ 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 and not writable by other users.
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
Expand All @@ -67,8 +69,43 @@ 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.

On macOS, a Homebrew or shared Python runtime can be rejected for scheduled
service use when any runtime component is group/world-writable. That rejection
is intentional; launchd runs the interpreter later, after the current shell is
gone. A safe pattern is to install the service from a private uv-managed venv
under the user's home directory.
### 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.
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "ephemdir"
version = "0.5.0"
version = "0.6.0"
description = "Create self-cleaning ephemeral directories that vanish after a lifetime or on restart."
readme = "README.md"
requires-python = ">=3.10"
Expand Down
2 changes: 1 addition & 1 deletion src/ephemdir/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@
tempdir,
)

__version__ = "0.5.0"
__version__ = "0.6.0"
__author__ = "vindfjur"

__all__ = [
Expand Down
Loading
Loading