After Dark Systems / Service Automation HubCompletely Programmable Infrastructure

OpenSAH / Farm tool guides

oos: out of space.

Find where the disk goes, check free space against a policy, and clean only what the configuration says is safe, into a quarantine you can restore.

Available Tag v0.7.1 MIT

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/fdinfo mount IDs; without them oos refuses to act. Process checks read /proc on Linux and use ps and lsof on macOS.

Install

go install github.com/afterdarksys/oos/cmd/oos@latest

Or build from a checkout:

git clone https://github.com/afterdarksys/oos.git
cd oos
go build -o oos ./cmd/oos
go install ./cmd/oos

The 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
oos

The 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 name

Edit ~/.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:

ActionMeaning
neverListed so it is measured and reported, never removed.
rm-contentsEmpty a directory and keep the directory.
rm-stale-childrenJudge each direct child on its own: keep it if a running process references it or anything under it changed within stale_after_hours.
rmRemove a file.
commandRun 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 BATCH moves everything back. It refuses to overwrite anything that has reappeared and refuses to restore under a protected path.
  • --purge -y permanently deletes batches older than quarantine_days.
  • --purge-now -y deletes 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-held deletes them.
  • --verify-quarantine checks manifests and identities; add --deep to check content hashes recorded when policy.quarantine_hash is 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: refused

Usage

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 nothing

Cleaning 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:

KeyEffect
min_free_gb, warn_free_gbCritical and warn thresholds for status, exit code and notification.
require_yesWithout --yes, cleanup and purge only print the plan.
max_delete_gb_per_runThe whole run is refused before anything is touched if the plan exceeds it.
allow_outside_homeDefault false: paths outside $HOME are refused.
allow_commandsDefault false: action: command entries are skipped and shown as refused.
never_touchAny path equal to or under these is refused.
always_disallowedExtra protected paths; adds to the built-in list and never replaces it.
min_path_depthRefuses shallow paths.
log_file, state_fileThe append-only audit log (a live run refuses to start if it cannot open it) and bigfile.json.
quarantine, quarantine_dir, quarantine_daysMove instead of delete, and when batches expire for --purge.
ownersPath or glob patterns that attribute bytes to a use case.
daemonThe resident watcher's interval, socket, alerting and the off-by-default auto_act.
fleet, fleet_timeout_secondsssh 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.

ExitMeaning
0OK
1Free space below warn (check modes)
2Below critical; --ensure missed its target; or every item was refused while executing
3Usage error or rejected config
4Busy: another oos mutation holds the lock; safe to retry
5Partial: some items done, others refused or failed
6I/O: audit log, quarantine, a record or a lock could not be written
7Nothing 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-agent installs an hourly quick check: a launchd agent on macOS or a systemd user timer on Linux (--system for root units on Linux). It notifies below the warn line or on a sudden drop. Remove it with --uninstall-agent.
  • oos --install-daemon installs the resident watcher, which replaces the hourly agent; oos --status asks it for the current state. It only watches unless policy.daemon.auto_act is true. Auto-act removals are permanent.
  • oos --fleet asks every ssh target in policy.fleet (or --hosts web1,edge1) for a quick check and prints one table, least free first.
  • deploy/deploy.sh [--yes] web1 edge1 builds a static binary per host architecture, seeds a server config you write yourself, and installs the system timer. Without --yes it 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.

Built from the public oos repository (README.md, CHANGELOG.md, the embedded default configs and the oos --help output) at commit 73b5056.