What it is
oos is a small Go tool that knows where the disk goes on a machine, reports headroom against a policy, and cleans up only the entries its configuration lists. Every cleanup is a dry run until you pass --yes, and by default each removal is a move into a quarantine batch that you can restore for a week. Use it when a disk is filling up, before a job that needs space, or as an hourly watcher that tells you before a volume runs out.
What it is not. oos is not a general file deleter. It refuses anything it cannot prove safe: a hard-coded list of operating-system and credential paths that configuration cannot lift, paths outside your home directory unless allowed, symlinks, shallow paths, entries a running process references, and any run larger than the per-run budget. It never runs docker volume prune and never deletes a Time Machine snapshot. Some modes, such as --ensure, --purge, --empty-trash and --permanent, remove data permanently with no restore.
Requirements
- macOS or Linux.
- Go 1.24 or later to build (from
go.mod). - On Linux, deletion requires
/proc/self/fdinfomount IDs; without them oos refuses to act. Process checks read/procon Linux and usepsandlsofon macOS.
Install
go install github.com/afterdarksys/oos/cmd/oos@latestOr build from a checkout:
git clone https://github.com/afterdarksys/oos.git
cd oos
go build -o oos ./cmd/oos
go install ./cmd/oosThe repository also has ./build.sh: ./build.sh install puts the binary in /usr/local/bin (GOBIN overrides), and ./build.sh test runs gofmt, vet and the test suite.
Verify the install. oos -V prints the version, and a bare oos is a quick, read-only check of free space against the policy:
oos -V
oosThe latest tag is v0.7.1. The default branch carries further unreleased changes listed in the changelog, including new exit codes; read it before upgrading a fleet.
Quick start
A first session on a workstation. Nothing below removes anything until the line with -y.
oos -i # write the platform default config to ~/.config/oos/oos.json
oos -s # show the resolved config, policy, state and quarantine batches
oos -c # full check: free space vs policy, and the size of every known entry
oos -A # audit ~ one level deep: size, age, use case, known or unknown
oos -C # cleanup plan (dry run)
oos -C -y # clean up for real, into quarantine
oos -s # the new quarantine batch is listed here
oos --restore 20260908-101500 # undo one batch by its nameEdit ~/.config/oos/oos.json before the first real cleanup. The audit's hints are written for the person editing that file: a name that looks like a cache, a git repository that belongs in never_touch, or a hidden directory over 1 GB.
Key concepts
Thresholds and status
policy.warn_free_gb and policy.min_free_gb set the warn and critical lines. Check modes exit 1 below warn and 2 below critical, so a script can act on the exit code alone. --warn GB and --critical GB override them for one run.
Known entries and actions
Cleanup only touches paths listed under known_dirs and known_files. Each entry has a type label and an action:
| Action | Meaning |
|---|---|
never | Listed so it is measured and reported, never removed. |
rm-contents | Empty a directory and keep the directory. |
rm-stale-children | Judge each direct child on its own: keep it if a running process references it or anything under it changed within stale_after_hours. |
rm | Remove a file. |
command | Run a shell command. Skipped unless policy.allow_commands is true (off in the shipped defaults). |
Quarantine and restore
With quarantine: true, each removal renames the path into quarantine_dir/<batch>/<original absolute path> and records it in the batch's manifest.json. Space is not freed until the batch is purged.
--restore BATCHmoves everything back. It refuses to overwrite anything that has reappeared and refuses to restore under a protected path.--purge -ypermanently deletes batches older thanquarantine_days.--purge-now -ydeletes every batch that is not held. Held batches (missing or invalid manifest, pending move, changed or unrecorded content) are listed with the reason, and only--include-helddeletes them.--verify-quarantinechecks manifests and identities; add--deepto check content hashes recorded whenpolicy.quarantine_hashis on.
Rename cannot cross filesystems, so an entry on a different device from the quarantine directory is refused in the plan. Additional same-filesystem stores are configured under policy.quarantine_volumes.
Guards
Before removing anything, each entry passes a fixed series of guards: not never; absolute; not /; not on the always-disallowed list; no macOS upgrade payload or installer process present; deep enough; under the home directory unless allowed; not under never_touch; exists; not a symlink; the right kind for the action; no guard process running; and on the same device as the quarantine. The always-disallowed list is hard-coded (oos -s prints this platform's list) and includes the system directories plus ~/.ssh, ~/.gnupg, ~/.aws, ~/.kube and other credential stores. policy.always_disallowed can only add to it.
Ask oos what it would do with a path, and which guard would stop it, with -W:
oos -W ~/.cache/pip # exit 0: would act, 1: unknown but allowed, 2: refusedUsage
Checking and finding space
oos # quick check: free space vs policy, no sizing
oos -c # full check: sizes every known entry
oos -c --fresh # remeasure everything and refresh the size cache
oos -d # what grew since the last recorded sizes
oos -S ~/Downloads # scan for big files, recorded in bigfile.json
oos --by-type ~/Downloads # totals by file type: disk images, archives, video ...
oos -S ~/Downloads --ext dmg,iso --older-than 90d --sort oldest --top 10
oos --who ~/.cache/uv # what a path is for, which processes use it, newest file
oos --history 24 # free-space readings, the trend and the forecast
oos --dupes ~/Downloads -m 200 # identical files over 200 MB, newest copy marked
oos --downloads # installed installers, extracted archives, copies, stale files
oos --scan-builds ~/src # build output per git repository, with guarded --add lines
oos --trash # measure the trash; removes nothingCleaning up
oos -C # cleanup plan, dry run
oos -C -y # cleanup for real (into quarantine)
oos -C -y -t cache # only entries of type cache
oos -C -t cache --tag build-output # plan narrowed by tag
oos -C -y -n # -n wins: still a dry run
oos -C -y --permanent # free space now instead of quarantining
oos --empty-trash --yes # permanently empty the trash (not quarantine, no restore)Editing the configuration from the command line
oos --add ~/.cache/foo --type cache --action rm-contents --note "seen 2026-09-08"
oos --add ~/x --type cache --action rm-contents --tags build-output,review
oos --forget ~/.cache/foo--add and --forget edit the config in place. The result is validated exactly as a load would be before the file is written, and a config file owned by another user, or writable by group or others, is refused.
Configuration
Lookup order: --config FILE (or -f), then ~/.config/oos/oos.json, then the default embedded in the binary (oos.json on macOS, oos.linux.json on Linux). The file must be owned by the effective user and not writable by group or others. Unknown keys are an error, so a typo cannot silently weaken the policy. The file has a policy block and the known_dirs and known_files lists. An entry looks like this:
{
"path": "~/.cache/pip",
"type": "cache",
"action": "rm-contents",
"note": "pip download cache"
}Entry fields: path, type, action, command (for action: command), stale_after_hours, use_case, guard_processes (substrings; a matching running process refuses the entry) and note.
The most important policy keys:
| Key | Effect |
|---|---|
min_free_gb, warn_free_gb | Critical and warn thresholds for status, exit code and notification. |
require_yes | Without --yes, cleanup and purge only print the plan. |
max_delete_gb_per_run | The whole run is refused before anything is touched if the plan exceeds it. |
allow_outside_home | Default false: paths outside $HOME are refused. |
allow_commands | Default false: action: command entries are skipped and shown as refused. |
never_touch | Any path equal to or under these is refused. |
always_disallowed | Extra protected paths; adds to the built-in list and never replaces it. |
min_path_depth | Refuses shallow paths. |
log_file, state_file | The append-only audit log (a live run refuses to start if it cannot open it) and bigfile.json. |
quarantine, quarantine_dir, quarantine_days | Move instead of delete, and when batches expire for --purge. |
owners | Path or glob patterns that attribute bytes to a use case. |
daemon | The resident watcher's interval, socket, alerting and the off-by-default auto_act. |
fleet, fleet_timeout_seconds | ssh targets for --fleet and the per-host deadline. |
The README documents every key, including Docker, snapshot, forecast and filesystem-budget settings.
In scripts and for agents
Every mode is exit-code first, and -j prints JSON (one document per line with a top-level kind).
oos -Q --critical 20 || exit 1 # pre-flight: fail under 20 GB, print nothing
[ "$(oos -F)" -ge 50 ] || oos -E 50 -y # bare integer GB; make room if needed
oos -W ~/.cache/foo -j | jq .verdict
oos -C -j | jq '.plan[] | select(.refused == null) | .path'
oos --log-tail 20 # what the last run actually did--ensure GB (-E) purges expired quarantine batches first, then removes configured entries largest first until the target is met. Its removals are permanent, and still logged.
| Exit | Meaning |
|---|---|
| 0 | OK |
| 1 | Free space below warn (check modes) |
| 2 | Below critical; --ensure missed its target; or every item was refused while executing |
| 3 | Usage error or rejected config |
| 4 | Busy: another oos mutation holds the lock; safe to retry |
| 5 | Partial: some items done, others refused or failed |
| 6 | I/O: audit log, quarantine, a record or a lock could not be written |
| 7 | Nothing actionable |
Exit codes 4 to 7 are documented on the default branch and listed in the unreleased section of the changelog. Before that change, a busy lock and a partial cleanup also exited 2.
Agent, daemon and fleet
oos --install-agentinstalls an hourly quick check: a launchd agent on macOS or a systemd user timer on Linux (--systemfor root units on Linux). It notifies below the warn line or on a sudden drop. Remove it with--uninstall-agent.oos --install-daemoninstalls the resident watcher, which replaces the hourly agent;oos --statusasks it for the current state. It only watches unlesspolicy.daemon.auto_actis true. Auto-act removals are permanent.oos --fleetasks every ssh target inpolicy.fleet(or--hosts web1,edge1) for a quick check and prints one table, least free first.deploy/deploy.sh [--yes] web1 edge1builds a static binary per host architecture, seeds a server config you write yourself, and installs the system timer. Without--yesit is a dry run that still probes each host read-only.
How it fits with the other tools
oos runs on its own. badfarm has a read-only check, health.oos-agent, that reads the oos timer, service and journal on a Linux host and reports a missing or failing agent, a rejected config, a stale tick or a warn or critical disk. Hosts expected to run oos can be named in badfarm's expected_oos_hosts policy. A farmops catalog entry can name badfarm check IDs such as this one in its prechecks; farmops scheduling itself is not implemented yet.
Links
- Full README on GitHub (every flag, policy key and guard)
- Changelog
- Filesystem release review
- Tagged versions (v0.7.1; no packaged release downloads)
- MIT license
- Issue tracker
Built from the public oos repository (README.md, CHANGELOG.md, the embedded default configs and the oos --help output) at commit 73b5056.