dsh-safe-delete
Every rm an agent runs becomes a recoverable move to the macOS Trash.
One global guard on the tools registry — ctx.tools.guard() —— no tool, prompt, or core changes
A DeepSeek Harness host-side plugin that makes agent-issued rm commands recoverable: targets are moved to the macOS Trash instead of deleted, across GUI sessions, automation runs, headless bridges, and subagents. One switch in Settings → General turns it off — disabled, commands run with native DSH behavior.
Capabilities
- Recoverable deletion — an
rm in command position is denied and rewritten to /usr/bin/trash -v; the model receives a report of exactly what was moved, so nothing silently disappears.
- Every session, one guard — registers once on the DSH tools registry; GUI chats, scheduled automations, headless bridges, and subagents are all covered with zero per-session wiring.
- Shell-aware interception — quote-preserving lexer catches
sudo rm, /bin/rm, env/nice-prefixed rm, VAR=x rm, and xargs rm inside compound commands (&&, ||, ;, |).
- Fail-safe by design — constructs that could hide an
rm ($(...), backticks, subshells, heredocs, eval, nested sh -c) are denied with guidance instead of rewritten; a failed trash is reported, never retried as real deletion.
- Runtime switch — toggle in the web GUI (Settings → General → Safe Delete) or via
POST /safe-delete/config; the state persists across restarts in $DSH_HOME/storages/safe-delete.json.
How it works
The plugin installs one guard on the DSH tools registry. Every bash tool call is scanned before execution:
flowchart LR
A["bash tool call"] --> B{"rm in command<br/>position?"}
B -- "no" --> C["native execution"]
B -- "yes" --> D{"switch on?"}
D -- "off" --> C
D -- "on" --> E{"safely<br/>rewritable?"}
E -- "no: subshell, eval,<br/>heredoc, backticks" --> F["deny with guidance,<br/>nothing runs"]
E -- "yes" --> G["run trash -v, deny the<br/>original, report targets"]
Intercepted calls are denied and re-executed as trash by the guard itself, so quoting and globs survive:
[dsh-safe-delete] intercepted `rm` — the targets were MOVED TO TRASH (recoverable),
not deleted. Original command denied. Targets: build/ dist
trash: build/ → .Trash/build/
Plain non-rm commands pass through untouched; commands that merely mention rm (echo rm, grep "rm " log) are ignored. rm flags (-f, -r, …) are stripped rather than honored: trash moves whole directories natively.
Scope and limits
| Covered | Not covered |
|---|
| Commands | rm, sudo rm, absolute-path rm, xargs rm — simple and compound | find -delete, unlink, git clean, language runtimes' own file APIs |
| Guarantee | intercepted at the guard layer, before execution | the guard is an accident-prevention net for the most common destructive verb, not a sandbox |
sudo rm is intercepted before sudo ever runs, but trashing files that require root can still fail — trash errors are reported verbatim.
Install
dsh plugin --profile web add github:NattoCB/dsh-safe-delete
Restart the DSH web process — host-side bundles and their patches load at process start. On boot the plugin logs rm guard active once.
Configuration
| Surface | How |
|---|
| Web GUI | Settings → General → Safe Delete — the switch takes effect on the next bash call, no reload |
| HTTP | GET /safe-delete/config → { "enabled": bool }; POST /safe-delete/config with { "enabled": bool } |
| File | $DSH_HOME/storages/safe-delete.json — { "enabled": bool } |
Default: enabled. A missing or corrupt state file also resolves to enabled — installing the plugin expresses the intent to have the guard, and a corrupt file never silently downgrades protection.
Development
npm test # node --test: lexer, rewrite matrix, guard contract, switch store/API
The lexer/rewriter is exported as exports._internals so tests exercise the real code paths (analyzeCommand, tokenize, splitSegments, makeGuard, ConfigStore, handleConfigRequest).
License
MIT — part of the awesome-dsh-plugin ecosystem.