Skip to main content
Glama
kienpt91

disk-janitor-mcp

by kienpt91

disk-janitor

Reclaim disk space with coding agents in the loop, without ever giving them rm -rf.

Agents and dev environments leave a lot of reclaimable junk behind: node_modules of stale projects, package-manager caches, build outputs, Docker build cache, toolchain caches. Letting an agent delete it with a shell command is dangerous: one bad path, one symlink, one race and a home directory is gone. disk-janitor makes unsafe deletion structurally hard:

  • Read-only by default, two phases. scan only looks. plan writes a reviewable JSON plan bound to a SHA-256 digest. Nothing moves until an approval exists for that exact digest.

  • An approval step that stops accidental and non-interactive runs. approve needs a real terminal on stdin and stdout, refuses when an agent or non-interactive marker is in the environment, shows every candidate, and requires the full plan digest typed back (it is not printed on the approve screen; you read it from explain). The MCP server has no approve tool and no purge tool. This is not a security boundary against an agent that runs as your OS user and can drive a pty: such an agent can run explain, read the digest and type it. For a real boundary use --approver dialog (an OS confirmation dialog, best effort) or run the tool under a separate OS account; see Approval: what the gate stops.

  • Quarantine, then purge, with undo. apply only renames directories into a quarantine on the same file system. restore is a faithful undo. purge (terminal, typed confirmation code) permanently deletes only quarantined items older than a TTL (7 days by default) that an authenticated manifest lists.

  • Strict allowlists. Five explicit detectors, no "delete big files". A protected-path denylist always wins. Roots must be passed explicitly; /, your home directory, its parents and system directories are refused.

  • Re-validation at apply time. Every candidate is checked again (same device and inode, no symlinks in the parent chain, nothing modified since the plan, still inside the roots, not protected, no open files). If anything differs, the whole apply aborts before the first move.

  • Hash-chained audit log of every domain event, and hostile file names (newlines, ANSI/OSC escapes, bidi controls, huge names) are escaped everywhere they could reach a terminal, a JSON file or an MCP client.

Platforms: macOS and Linux. Windows is not supported.

Not a replacement for backups. Quarantined items live on the same disk until purged, and no tool that moves files can promise you against every bug, every race or a compromised user account. Keep backups. See Limitations and SECURITY.md for exactly what is and is not defended.

What it will never touch

The protected-path denylist beats every other rule, including a plan that was edited and approved:

.git directories (and anything that contains a nested .git), .ssh, .gnupg, .aws, .config, .kube, .azure, .docker, keychains, .env* files, Documents, Desktop, Downloads, Pictures, Music, Movies, iCloud / Dropbox / OneDrive / Google Drive style sync folders, database data directories (pgdata, PG_VERSION, ibdata1, mongod.lock, ...), anything named like a secret or key material, and the home directory itself. It also never follows symlinks, never crosses a file system boundary, never touches a Docker volume or a running container, and never removes a git worktree (worktrees are report-only).

Related MCP server: DevGuard-MCP

Install

disk-janitor is not published to a registry yet. Build from source (Node.js >= 22.12):

git clone <this repository> disk-janitor && cd disk-janitor
npm ci --ignore-scripts
npm run build
node dist/infrastructure/cli/bin.js --help      # or: npm link   ->   disk-janitor, disk-janitor-mcp

How a human uses it

# 1. look (read-only). There is no default root: you must name one.
disk-janitor scan --root ~/projects --caches

# 2. write a plan. Nothing is changed. The plan lands in ~/.local/state/disk-janitor/plans/
disk-janitor plan --root ~/projects --caches
disk-janitor explain <plan id>

# 3. approve it, in a real terminal. `explain` printed the full digest; approve lists every
#    candidate and asks you to type that digest back (it is not shown on the approve screen)
disk-janitor approve <plan id>
#    or, best effort, answer a native OS dialog instead of the terminal:
disk-janitor approve <plan id> --approver dialog

# 4. apply: moves the candidates into quarantine (use the same --root/--caches flags as `plan`)
disk-janitor apply <plan id> --root ~/projects --caches

# 5. changed your mind?
disk-janitor restore <plan id>              # or: disk-janitor restore /path/to/node_modules

# 6. free the space for real, after the TTL (default 7 days). Human-only.
disk-janitor purge --dry-run      # prints the confirmation code
disk-janitor purge                # asks you to type that code

Other commands: status (state directory, quarantine, audit chain), audit (prints the JSONL audit log and verifies its chain), doctor (state-dir permissions, key file mode, same-device rename support, git / lsof / docker availability).

Flags: --root <dir> (repeatable), --config <file>, --state-dir <dir>, --home <dir>, --caches, --docker-apply, --detectors a,b, --stale-days, --build-min-age-days, --cache-min-age-days, --min-size <bytes>, --out <file>, --minutes <n> (approval validity, 15 by default, 60 at most), --approver terminal|dialog, --ttl-days <n>, --dry-run, --json, --verbose. Plans bind the configuration that made them: apply refuses a different configuration, so keep the flags (or a --config file) identical between plan and apply.

The state directory defaults to $XDG_STATE_HOME/disk-janitor or ~/.local/state/disk-janitor (mode 0700; refused when it is a symlink, group- or world-writable, or inside a root).

What the real terminal flow looks like

approve and purge read from a TTY. The sessions below were produced by the real binary in a container against a throwaway fake $HOME; the "terminal" is a pty created with script(1), and the line typed is the full digest the human copied from explain (for purge, the code printed by purge --dry-run). The same pty trick is available to any process running as your user: see the next section.

Approval: what the gate stops, and what it does not

It stops: accidental applys, scripts and agent tool calls that have no terminal, CI jobs, and a plan someone edited after review (the digest changes, so no approval matches). It does not stop a process that runs as your OS user, has a shell and can allocate a pty (script(1), expect, any pty library): that process can run disk-janitor explain, read the digest and type it into disk-janitor approve; and because the approval key is a file your user can read, it can also forge an approval or simply run rm. The demo below approves exactly that way, with script(1) supplying the terminal.

Ways to get a real boundary:

  1. A different OS user (recommended). Run disk-janitor and disk-janitor-mcp as a dedicated account (say janitor) that owns the state directory (0700) and has read/write access to the roots. Start the MCP server as that account (a restricted sudoers rule or a service) so the agent only talks to the server; approve with sudo -u janitor disk-janitor approve <plan id>. The key is unreadable to the agent's account. A root-owned approval key and approvals/ directory with a small root-run approver is the same idea.

  2. --approver dialog (best effort). The decision comes from a native confirmation dialog (osascript on macOS, zenity or kdialog on Linux) instead of the terminal. The command line is a fixed argument vector and the dialog text contains only the plan id, a digest prefix and numbers. A process that merely drives a pty cannot answer it; an agent that can click UI elements (macOS accessibility permission, a shared X11 session) can. It is unavailable on a headless host and never falls back to the terminal.

An HMAC approval needs the verifier to hold the key, so any account that can run apply can forge approvals; both options work by ensuring that account is not one the agent controls.

A real session

Fake $HOME with a realistic mixed tree (stale and fresh projects, a dirty git repo, a symlinked node_modules aimed at a victim directory, a Python project, an npm cache, and tripwire files in ~/Documents and ~/.ssh). Run by scripts/demo.sh inside a container; the output is pasted unedited.

Scan (read-only):

$ disk-janitor scan --root /tmp/dj-demo/home/projects --home /tmp/dj-demo/home --caches --state-dir /tmp/dj-demo/state
disk-janitor scan (read-only: nothing was changed)
roots: /tmp/dj-demo/home/projects
open-file check: NOT performed (lsof unavailable); make sure nothing is using these directories

      SIZE   AGE  DETECTOR      PATH
   5.0 MiB    90d  package-cache /tmp/dj-demo/home/.npm/_cacache
            why: npm cache at its default location, nothing inside modified for 90 days
            regenerate: npm re-downloads packages on demand
   4.0 MiB    90d  build-output  /tmp/dj-demo/home/projects/old-api/target
            why: build output target, nothing inside modified for 90 days; allowlisted name next to Cargo.toml (no git repository)
            regenerate: cargo build or mvn package
   3.0 MiB    90d  node-modules  /tmp/dj-demo/home/projects/webapp/node_modules
            why: project untouched for 90 days; package.json and package-lock.json present; git working tree clean
            regenerate: npm ci (in /tmp/dj-demo/home/projects/webapp)
   2.5 MiB    90d  node-modules  /tmp/dj-demo/home/projects/old-api/node_modules
            why: project untouched for 90 days; package.json and pnpm-lock.yaml present; no git repository
            regenerate: pnpm install --frozen-lockfile (in /tmp/dj-demo/home/projects/old-api)
   2.0 MiB    90d  build-output  /tmp/dj-demo/home/projects/pylib/pkg/__pycache__
            why: build output __pycache__, nothing inside modified for 90 days; allowlisted name next to mod.py (no git repository)
            regenerate: python recompiles bytecode on import
   2.0 MiB    90d  build-output  /tmp/dj-demo/home/projects/webapp/dist
            why: build output dist, nothing inside modified for 90 days; git-ignored and untracked
            regenerate: run the project's build (for example npm run build)

total reclaimable: 18.5 MiB in 6 candidates

report-only findings (never acted on):
  [docker] docker unavailable: the docker CLI was not found

skipped: 3 (use --verbose to list them with reasons)

What was deliberately left alone (--verbose):

skipped: 3 (listed below)
  [node-modules] /tmp/dj-demo/home/projects/wip/node_modules: git working tree has uncommitted or untracked changes
  [node-modules] /tmp/dj-demo/home/projects/trap/node_modules: node_modules is a symlink (never followed)
  [node-modules] /tmp/dj-demo/home/projects/fresh/node_modules: activity 0 days ago, minimum age is 30 days

Plan (nothing changed yet):

$ disk-janitor plan --root /tmp/dj-demo/home/projects --home /tmp/dj-demo/home --caches --state-dir /tmp/dj-demo/state
plan:       plan-20261031T062828Z-582414af
digest:     37ccd3c298e7c3c850abaf1e0df46722229368d45fda0d821250f1f38e6802ce
created:    2026-10-31T06:28:28.990Z
roots:      /tmp/dj-demo/home/projects
candidates: 6 (6 directories to quarantine, 0 docker prune), 18.5 MiB
     5.0 MiB  /tmp/dj-demo/home/.npm/_cacache
     4.0 MiB  /tmp/dj-demo/home/projects/old-api/target
     3.0 MiB  /tmp/dj-demo/home/projects/webapp/node_modules
     2.5 MiB  /tmp/dj-demo/home/projects/old-api/node_modules
     2.0 MiB  /tmp/dj-demo/home/projects/pylib/pkg/__pycache__
     2.0 MiB  /tmp/dj-demo/home/projects/webapp/dist
plan file:  /tmp/dj-demo/state/plans/plan-20261031T062828Z-582414af.json

nothing was changed. Review it with:
  disk-janitor explain plan-20261031T062828Z-582414af
then a human approves it in a terminal:
  disk-janitor approve plan-20261031T062828Z-582414af

An agent (no terminal) cannot approve, and apply without approval is refused:

# an agent (no terminal) cannot approve:

$ disk-janitor approve plan-20261031T062828Z-582414af --state-dir /tmp/dj-demo/state
error: approve needs an interactive terminal on stdin and stdout; refusing in a non-interactive session

# and apply without approval is refused:

$ disk-janitor apply plan-20261031T062828Z-582414af --root /tmp/dj-demo/home/projects --home /tmp/dj-demo/home --caches --state-dir /tmp/dj-demo/state
error: apply refused: no human approval exists for this plan digest. A human must run 'disk-janitor approve plan-20261031T062828Z-582414af' in a terminal

A human approves at a terminal:

# a human reads the digest in the explain output above and types it at a real terminal
# (the terminal here is a pty from script(1); any same-user process that can allocate a pty can do this too, see SECURITY.md):
$ disk-janitor approve plan-20261031T062828Z-582414af
37ccd3c298e7c3c850abaf1e0df46722229368d45fda0d821250f1f38e6802ce
plan:       plan-20261031T062828Z-582414af
created:    2026-10-31T06:28:28.990Z
roots:      /tmp/dj-demo/home/projects
candidates: 6 (6 directories to quarantine, 0 docker prune), 18.5 MiB

Approving lets 'apply' move the directories listed below into quarantine for 15 minutes.
Nothing is deleted by apply; directories stay restorable until a separate 'purge'.

Every candidate follows:
     5.0 MiB  /tmp/dj-demo/home/.npm/_cacache
     4.0 MiB  /tmp/dj-demo/home/projects/old-api/target
     3.0 MiB  /tmp/dj-demo/home/projects/webapp/node_modules
     2.5 MiB  /tmp/dj-demo/home/projects/old-api/node_modules
     2.0 MiB  /tmp/dj-demo/home/projects/pylib/pkg/__pycache__
     2.0 MiB  /tmp/dj-demo/home/projects/webapp/dist

Type the full plan digest to approve. It is not shown on this screen: copy it from `disk-janitor explain <plan id>`. Anything else cancels: 37ccd3c298e7c3c850abaf1e0df46722229368d45fda0d821250f1f38e6802ce
approved until 2026-10-31T06:43:29.756Z. Run: disk-janitor apply plan-20261031T062828Z-582414af

Apply moves the candidates into quarantine:

$ disk-janitor apply plan-20261031T062828Z-582414af --root /tmp/dj-demo/home/projects --home /tmp/dj-demo/home --caches --state-dir /tmp/dj-demo/state
applied plan-20261031T062828Z-582414af: 6 directories moved to quarantine
     5.0 MiB  /tmp/dj-demo/home/.npm/_cacache
     4.0 MiB  /tmp/dj-demo/home/projects/old-api/target
     3.0 MiB  /tmp/dj-demo/home/projects/webapp/node_modules
     2.5 MiB  /tmp/dj-demo/home/projects/old-api/node_modules
     2.0 MiB  /tmp/dj-demo/home/projects/pylib/pkg/__pycache__
     2.0 MiB  /tmp/dj-demo/home/projects/webapp/dist
quarantine: /tmp/dj-demo/state/quarantine/plan-20261031T062828Z-582414af
undo with:  disk-janitor restore plan-20261031T062828Z-582414af

Restore one item, look at what a purge would delete, and purge as a human:

# undo one item by path:

$ disk-janitor restore /tmp/dj-demo/home/projects/webapp/node_modules --state-dir /tmp/dj-demo/state
restored /tmp/dj-demo/home/projects/webapp/node_modules

# what would a purge delete? (TTL 0 only for this demo; the default is 7 days)

$ disk-janitor purge --state-dir /tmp/dj-demo/state --ttl-days 0 --dry-run
purge would permanently delete 5 quarantined item(s), 15.5 MiB:
     5.0 MiB  0d in quarantine  /tmp/dj-demo/home/.npm/_cacache  (plan-20261031T062828Z-582414af)
     4.0 MiB  0d in quarantine  /tmp/dj-demo/home/projects/old-api/target  (plan-20261031T062828Z-582414af)
     2.5 MiB  0d in quarantine  /tmp/dj-demo/home/projects/old-api/node_modules  (plan-20261031T062828Z-582414af)
     2.0 MiB  0d in quarantine  /tmp/dj-demo/home/projects/pylib/pkg/__pycache__  (plan-20261031T062828Z-582414af)
     2.0 MiB  0d in quarantine  /tmp/dj-demo/home/projects/webapp/dist  (plan-20261031T062828Z-582414af)
(dry run: nothing deleted; confirmation code for a real purge: 9a4cde7a)

# purge needs a terminal too: without one it refuses

$ disk-janitor purge --state-dir /tmp/dj-demo/state --ttl-days 0
error: purge needs an interactive terminal on stdin and stdout; refusing in a non-interactive session

# a human types the confirmation code printed by the dry run, at a real terminal:
$ disk-janitor purge --ttl-days 0
9a4cde7a
purge would permanently delete 5 quarantined item(s), 15.5 MiB:
     5.0 MiB  0d in quarantine  /tmp/dj-demo/home/.npm/_cacache  (plan-20261031T062828Z-582414af)
     4.0 MiB  0d in quarantine  /tmp/dj-demo/home/projects/old-api/target  (plan-20261031T062828Z-582414af)
     2.5 MiB  0d in quarantine  /tmp/dj-demo/home/projects/old-api/node_modules  (plan-20261031T062828Z-582414af)
     2.0 MiB  0d in quarantine  /tmp/dj-demo/home/projects/pylib/pkg/__pycache__  (plan-20261031T062828Z-582414af)
     2.0 MiB  0d in quarantine  /tmp/dj-demo/home/projects/webapp/dist  (plan-20261031T062828Z-582414af)
This cannot be undone.
Type the confirmation code printed by `disk-janitor purge --dry-run` to permanently delete them, anything else cancels: 9a4cde7a
purged 5 item(s)

The tripwires survive (~/Documents/taxes.txt, ~/.ssh/id_demo, the symlink victim, and the restored webapp/node_modules):

# tripwires (must all still be there):
tax records
-----fake key material-----
precious
node_modules
package-lock.json
package.json
src

Detectors

Every detector is an explicit allowlist with unit tests; none deletes "big files" generically.

Detector

Offers

Only when

node-modules

node_modules of a project

package.json and a lockfile (package-lock.json, npm-shrinkwrap.json, yarn.lock, pnpm-lock.yaml, bun.lock, bun.lockb) exist, so reinstalling is reproducible; the project is untouched for N days (default 30) by the mtime of package.json, the lockfile, the newest source file and node_modules; and, if it is a git repo, the working tree is clean (no uncommitted or untracked-non-ignored changes). node_modules that is a symlink is never offered.

build-output

.next, dist, build, target, .turbo, .cache/vite, __pycache__, .pytest_cache, .mypy_cache, coverage

the directory is git-ignored and holds no tracked files (git check-ignore), or the project has no git and the name is on the allowlist and a sibling manifest identifies the project type (Cargo.toml for target, package.json for dist, a .py file for __pycache__, ...). Nothing inside modified for 7 days (default).

package-cache (opt-in: --caches)

npm _cacache, pnpm store, yarn caches, pip cache, cargo registry cache, Homebrew downloads, Gradle caches

found at the documented default location below --home only, with no symlink in the path.

docker

report-only: parsed docker system df. With --docker-apply: build cache and dangling images

pruned only after approval, through two fixed argument lists (builder prune --force, image prune --force). Never volumes, never running containers, never -a. Irreversible (not quarantined).

git-worktrees

report-only: prunable worktrees

never removed by this tool; the report tells you to review and run git worktree prune yourself.

git is run defensively inside your projects: with an isolated environment (GIT_CONFIG_GLOBAL/GIT_CONFIG_SYSTEM set to /dev/null, no hooks, no attributes or excludes file, no fsmonitor), and a repository whose own config defines commands (clean filters, core.fsmonitor, core.hooksPath, textconv, merge drivers, includes, credential helpers...) is skipped. Your global gitignore is therefore not consulted: a directory ignored only there is not offered.

All candidates also pass shared checks: minimum size (default 1 MiB), minimum age, protected-path denylist, same file system as the root, no special files, no nested .git, no .env* / private-key / database-marker files inside, a depth and entry-count limit, no hard link shared with a file outside the candidate (so e.g. a pnpm store whose files are hard-linked into projects is refused), and no open file handles (best effort with lsof). At apply time every candidate is checked again, including that it still is a catalogue item (a node_modules next to a package.json and lockfile, one of the exact build-output paths next to a manifest or inside a git repository, or an exact package-cache location), so a hand-written plan cannot get an arbitrary directory quarantined.

MCP server

disk-janitor-mcp is a stdio MCP server for MCP clients. Roots come from the server's flags or config file, never from tool arguments, so an agent cannot widen its own scope.

{
  "mcpServers": {
    "disk-janitor": {
      "command": "node",
      "args": [
        "/path/to/disk-janitor/dist/infrastructure/mcp/bin.js",
        "--root", "/home/me/projects",
        "--caches"
      ]
    }
  }
}

Tool

Effect

scan

read-only: candidates with size, age, why safe, how to regenerate

plan

plan-only: writes a plan file, changes nothing, returns plan id and digest

explain_plan

read-only: shows a stored plan and verifies its digest

status

read-only: roots, audit-chain integrity, quarantine count

list_quarantine

read-only: quarantined items and their status

restore_preview

read-only: whether a plan's items could be restored

apply_plan

moves candidates into quarantine only if a valid, unexpired, single-use approval exists for exactly this plan digest, created by the user with disk-janitor approve; re-validates every candidate (including that it is a catalogue item) first. If docker apply is enabled it also prunes docker build cache and dangling images, which is irreversible and not quarantined

There is no approve tool and no purge tool, and the server's instructions tell the agent that the approval step is the user's decision and is not a security boundary against a same-user process. Every file-system string in a tool result, including errors and warnings, is wrapped in an <untrusted-data> frame with control and invisible characters escaped, so a hostile directory name cannot talk to the model.

Configuration file

--config <file> takes strict JSON (unknown keys are rejected, at most 64 KiB); flags override it:

{
  "roots": ["/home/me/projects"],
  "stateDir": "/home/me/.local/state/disk-janitor",
  "detectors": ["node-modules", "build-output", "git-worktrees", "docker"],
  "packageCaches": true,
  "dockerApply": false,
  "staleDays": 30,
  "buildMinAgeDays": 7,
  "cacheMinAgeDays": 7,
  "minSizeBytes": 1048576,
  "maxDepth": 8,
  "maxCandidates": 500,
  "maxEntriesPerCandidate": 500000
}

Architecture

Domain-driven, with a dependency rule that a test enforces. See docs/domain.md for the ubiquitous language and the context map (Discovery, Planning, Approval, Disposal, Audit) and docs/adr for the decisions.

src/domain/          pure: entities, value objects, aggregates (Plan, Approval, Manifest, AuditChain, ApplyRun),
                     policies (protected paths, roots, human session), domain events. No IO, clock or randomness.
src/application/     use cases (discover, create plan, approve, apply, restore, purge) and the ports they need.
src/infrastructure/  adapters: node fs, git / lsof / docker CLIs, state files, lock, audit log, CLI, MCP server.
src/composition/     wires adapters into use cases; the convenient library API.

test/architecture.test.ts scans every import and fails when the domain imports anything but the domain, when the application imports anything but domain and application, when a driven adapter imports the composition root, or when pure layers touch the file system, process, clock or randomness. The checker has its own self-tests so it cannot pass vacuously.

Tests and coverage

Tests are organized by layer. Counts are from a full run:

Suite

Tests

What it checks

test/architecture.test.ts

13

the dependency rule (and the checker itself)

test/domain

1021

pure unit and property-style tests of invariants, state transitions and domain events (seeded generators)

test/application

350

use cases driven with in-memory fakes of every port; assertions on results, state and ordered events

test/infrastructure

399

contract suites: the same tests run against the in-memory fake and the real adapter in temp directories (file system, git, lsof, docker CLI, plan / approval / key / audit / quarantine stores, lock)

test/integration

750

real directories and real git repositories through the composition API: every detector, symlink / race / TOCTOU / path-traversal / cross-device / hard-link attacks, plan tampering, approval forgery, hostile names, CLI and MCP in-process

test/e2e

16

the real built binaries (disk-janitor, disk-janitor-mcp over stdio)

total

2549

A tripwire home directory (with ~/Documents, ~/.ssh and a decoy project) is created per run, HOME points at it for every test, and the suite fails in teardown if a single byte under it changed.

Coverage is measured with vitest's v8 provider (npm run coverage) over src/, excluding the two small bin entry points (the e2e tests run them, but in a child process the provider cannot see):

layer            lines  statements  branches  functions   target
domain            99.7%      99.7%     98.9%     100.0%   >= 95% met
application       99.4%      98.4%     95.0%      91.7%   >= 90% met
infrastructure    97.3%      94.2%     83.9%      92.7%   -
composition      100.0%      97.6%     90.0%      97.0%   -
overall           98.8%      97.6%     93.7%      96.4%   >= 85% met

Line coverage of the infrastructure layer varies between 97.1% and 97.3% from run to run (and between Node 22 and 24); the other numbers were identical on both. Targets (enforced by vitest.config.ts thresholds and scripts/coverage-summary.mjs): domain >= 95% lines, application >= 90%, overall >= 85%. The numbers above were identical on Node 22 and Node 24 in the final run (infrastructure line coverage moved by +-0.2 points between earlier runs). Least covered files, honestly: application/inspection.ts 92.3% (the rethrow of an unexpected errno from lstat), infrastructure/process/command-runner.ts 92.8% lines and 56.5% branches (the execFile error branches that need a real failing or timing-out process), infrastructure/cli/cli.ts 94.7% (realIO, the real-terminal adapter, which only runs inside the spawned binary where the provider cannot see it, and a few doctor failure branches), application/discover.ts 95.5%. Branch coverage of the infrastructure layer is the weakest number (83.9%): defensive catch branches around races and permission errors that cannot be provoked deterministically in a container. Coverage says what ran, not that the assertions are strong; the symlink / race / tamper suites assert exact outcomes (tree hashes unchanged, victim directories intact, exact error codes) rather than just exercising the code.

Run everything inside a throwaway container (nothing runs on your host):

sh scripts/test-in-container.sh            # npm ci, typecheck, all layers, coverage, build (IMAGE=node:24 for Node 24)
# or, in any Node 22/24 environment with git:
npm ci --ignore-scripts && npm run test:layers && npm run coverage

npm run test:architecture | test:domain | test:application | test:infrastructure | test:integration | test:e2e run a single layer.

Limitations

  • Not a security boundary against a same-user process. The approval gate (TTY, environment markers, a typed digest) can be satisfied by any process that runs as your user and can allocate a pty, and the approval key is readable by that user. Use a separate OS account or --approver dialog for a real boundary; see Approval: what the gate stops and SECURITY.md.

  • Rename races cannot be eliminated. Node.js has no directory-relative rename. A swap that wins the race between the last check and the rename is detected afterwards and undone, and a swap of a parent directory is caught by the same identity check, but the window exists.

  • The open-file check needs lsof and only sees your own processes. doctor and scan say when it is off.

  • Candidates must be on the same file system as the state directory (quarantine is a rename; there is no copy fallback). Otherwise they are refused.

  • Hard-linked stores are refused: a pnpm store (or node_modules) whose files are hard-linked outside the candidate is not offered.

  • Docker prunes are irreversible and not quarantined; they run against whichever daemon DOCKER_HOST / DOCKER_CONTEXT select, which the approval screen prints.

  • Space is freed on purge, not on apply.

  • The audit chain detects edits, deletions and reordering, not truncation of its tail.

  • Windows is not supported.

What was not verified here

All development and test runs happened on Linux (arm64, amd64 for the lockfile check) in containers. macOS-specific locations (~/Library/Caches/..., ~/Library/pnpm/store, /private/var/... system paths, APFS block accounting) are covered by path-string tests only, not on a real macOS volume. lsof is not installed in the test container, so the open-file protection is tested through an injected command runner, not against real open files. Docker is tested through an injected command runner; no real daemon was used. A real second file system could not be mounted, so cross-device refusal is tested with an injected lstat and a fake that raises EXDEV.

Dependency policy

Every npm package version, direct and transitive, was published on or before 2026-10-24T00:00:00Z; direct dependencies are pinned exactly; installs use --ignore-scripts (including CI). The core has no runtime dependencies beyond Node.js; the MCP server uses @modelcontextprotocol/sdk and zod. See SECURITY.md.

Secrets and hooks

Never commit .env files or credentials. Keep real values in a local, git-ignored .env and commit only a .env.example with placeholders. (This tool itself reads no secrets: its only key is the per-user approval key in the state directory, which is never inside a repository.)

Install lefthook and run lefthook install once per clone. This enables two hooks (see lefthook.yml):

  • pre-commit runs sh scripts/secret-scan.sh --staged and blocks a commit that stages a secret-like file or key.

  • pre-push runs sh scripts/secret-scan.sh --tree and blocks a push if any tracked file contains one.

CI has a separate secret-scan job that runs the same scanner over the working tree (--tree) and over the added lines of every commit reachable from HEAD (--history). The scanner is a dependency-free shell script; findings print the file, line and rule name, never the secret. A finding that is a reviewed, harmless fixture can be allowed by adding a line <path> <rule> # reason to .secret-scan-allow (one entry per path and rule, no wildcards), or by putting the marker secret-scan: allow on the offending line. .gitignore also lists the usual secret-bearing file names.

If a secret is ever committed, treat it as compromised: rotate or revoke it first, then remove it from git history.

License

MIT, see LICENSE.

Related MCP Connectors

Related MCP Servers

  • A
    license
    Not graded
    quality
    B
    maintenance
    Enables LLM agents to safely reclaim disk space by deleting build artifact directories like node_modules, .venv, and target, with strong guardrails to prevent accidental or malicious deletion.
    MIT
  • A
    license
    A
    quality
    A
    maintenance
    Enables AI assistants to audit and clean development workspaces safely by detecting reclaimable artifacts, exposed .env files, and potential secret leaks.
    6
    MIT
  • A
    license
    Not graded
    quality
    B
    maintenance
    Enables AI coding assistants to scan a repository for code that cannot prove its usefulness, attribute it to AI-assisted or human commits, prove candidate deletions against the project's own tests, and pull a payoff report or run history, all without ever writing to the repository. The test command and any model judging stay under a person's control through environment configuration.
    1
    MIT
  • A
    license
    Not graded
    quality
    C
    maintenance
    Enables AI agents to scan a developer machine for disk space hogs, audit git worktrees for stale or orphaned ones, re-check individual findings, and run allowlisted cleanup actions as dry runs unless explicitly confirmed. Risky items are reported with warnings and refused by default.
    1
    MIT