dsh-safety
English | 中文
DeepSeek Harness 文件系统安全护栏:拦截破坏性操作 · 删除可恢复 · 组合可回滚 · 启动前校验
execution-time guard · safe_delete · composition snapshots · pre-restart check · standalone CLI
What · Features · Install · Quick start · CLI · Config · Design · FAQ · Known limitations
What
A filesystem safety harness for DeepSeek Harness (DSH). It enforces a three-tier file policy at the tool-execution boundary: destructive agent calls are denied before they run, every delete is routed through a recoverable trash, the plugin composition can be snapshotted and rolled back, and the composition is validated before a restart.
The package has zero runtime dependencies. It installs as a standard DSH profile bundle and also ships a standalone CLI, so the recovery layer remains usable when DSH itself will not start.
Background — the guard rules are derived from a real production incident: a script silently resolved the wrong path (PowerShell's
$HOMEis read-only) andRemove-Item -Recurse -Forcedeleted an entire engine runtime root. The directory was recoverable only because it was generated content; hand-authored files would have been lost permanently. The plugin turns the lessons of that incident into enforced mechanisms rather than documentation.
Features
- Execution-time guard (
ctx.tools.guard): denies destructive tool calls before they run.- Recursive directory deletes are blocked everywhere (
rm -r/-rf,Remove-Item -Recurse,rd /s,rmdir,shutil.rmtree,fs.rm recursive,require('fs').rmSync…) — no matter which path, routed tosafe_delete. write/edit/str_replace_editoron protected paths (profilepackage.json,cordis.patch.yml,cordis.yml, lockfiles,node_modules, the deployment install dir, home patch/settings) are denied.- Deletes on confirm zones (the whole OS home dir, plugin sources,
agent presets) are denied and routed to
safe_delete. run_codebodies are scanned too — arbitrary code execution cannot hide anfs.rmSync/shutil.rmtreeon a protected zone behind a tool call boundary.- Variable-reference deletes are caught —
Remove-Item "$env:USERPROFILE\.dsh\…"whose literal path only exists after expansion is denied (the reference + tail fragment is matched against protected markers).
- Recursive directory deletes are blocked everywhere (
safe_delete— the only sanctioned delete channel. Moves to a trash directory (recoverable viasafety_undo),preview:trueshows what would be removed first, refuses filesystem roots and its own state dir, and journals every delete.- Composition snapshots —
safety_snapshotsaves the whole plugin composition (per-profile manifests, patches, lockfiles, pluginpackage.json+cordis.patch.yml, agent presets) with SHA-256 hashes;safety_restorerolls back to a last-known-good state (current files are backed up first). Credential-bearing files are excluded by default. - Pre-restart check —
safety_checkvalidates UTF-8, detects mojibake (wrong-encoding round-trips, the classic "DSH won't open" cause), JSON parse errors, and duplicate plugin row ids across patch layers (the "one row, one layer" rule). - Audit journal + web panel — every block/delete/snapshot/restore is journaled; a "Safety Center" settings section shows trash, snapshots, journal, and one-click restore/rollback.
- Standalone CLI —
dsh-safetyworks without DSH: delete/undo/snapshot/ restore/check from your own terminal, even when DSH won't boot.
Install
System requirements: a working DeepSeek Harness (dsh web boots). npm
install has no extra requirements; installing from the repository needs
Node.js >= 22 and pnpm.
From npm (recommended)
dsh plugin --profile web add @suagr_xl/dsh-safety # install from the official npm registry / 从官方 npm registry 安装
dsh plugin runs pnpm and reconciles dsh.profile.bundles automatically
because this package declares dsh.bundle. Restart dsh web — the guard is
then active and the safety_* tools appear.
From the repository (development)
git clone https://github.com/sugarxl/dsh-safety.git # clone the repo / 克隆仓库
cd dsh-safety # enter the directory / 进入目录
dsh plugin --profile web add link:$(pwd) # symlink the repo into the profile / 把仓库软链进 profile
The link: protocol symlinks the repo (changes to lib/ apply after a
restart), unlike file: which copies a snapshot. dsh plugin reconciles the
bundle automatically. Note: the profile directory is not a pnpm workspace, so
any workspace:* deps would fall back to the npm registry — this plugin has
zero runtime dependencies at all (its imports are only Node builtins +
its own safety-core.mjs), so a bare link: install works with no
node_modules of its own and no fallback is needed.
Where it lands (official layout)
Both installs go through the official dsh plugin mechanism — nothing else to
configure:
$DSH_HOME/profiles/<name>/package.json # + dependency + dsh.profile.bundles / 新增依赖 + dsh.profile.bundles
$DSH_HOME/profiles/<name>/node_modules/dsh-safety/ # the installed package / 安装的包本体
The bundle layer is read at boot from the package's own cordis.patch.yml.
The dsh-safety row id appears in exactly one layer (that file); never add it
to the profile or home cordis.patch.yml.
Verify & uninstall
dsh --profile web --dump-config | grep -i dsh-safety # row present / 确认行出现
dsh-safety check # pre-restart gate / 重启前体检
# restart dsh web / 重启 dsh web
# uninstall: / 卸载:
dsh plugin --profile web remove @suagr_xl/dsh-safety
# restart dsh web / 重启 dsh web
Install troubleshooting
- Installed, restarted, but nothing changed: restart the whole
dsh webprocess — a page refresh is not enough. Confirm the row is mounted withdsh --profile web --dump-config. ERR_PNPM_IGNORED_BUILDS: pnpm blocks dependency build scripts; add the listed packages topnpm-workspace.yamlallowBuildsand re-run.- pnpm release-age gate installs an old version: pnpm 11's
minimumReleaseAgecan silently pick an older publish within ~10 days; addminimumReleaseAgeExclude: ['@suagr_xl/dsh-safety']to the profile'spnpm-workspace.yamland rundsh plugin --profile web update @suagr_xl/dsh-safety.
Standalone CLI (no plugin install needed)
npm link # or: node bin/dsh-safety.mjs ...
dsh-safety status
The CLI reads the same $DSH_HOME/.dsh-safety state the plugin uses, so you
can undo/restore from your terminal even if DSH is down.
Quick start
# 1. Inspect the effective policy zones
dsh-safety policy
# 2. Snapshot before editing any composition file
dsh-safety snapshot before-edit
# 3. Delete through the safe channel (preview first, then execute)
dsh-safety delete path/to/file --preview
dsh-safety delete path/to/file
# 4. Recover a delete
dsh-safety trash
dsh-safety undo <trash-id>
# 5. Boot failure: validate, then roll back
dsh-safety check
dsh-safety status # list snapshots
dsh-safety restore <snapshot-id> --confirm
CLI reference
dsh-safety status state: trash, snapshots, journal
dsh-safety delete <path> [--force] [--preview]
dsh-safety trash [--limit N]
dsh-safety undo <id>
dsh-safety snapshot [label] [--exclude a,b]
dsh-safety restore <id> --confirm
dsh-safety check exit 1 on failure (CI-friendly)
dsh-safety journal [n]
dsh-safety policy effective policy zones
dsh-safety help
--home <path> overrides the state root ($DSH_HOME or ~/.dsh by default).
Model-facing tools (when installed as a plugin)
| Tool | Purpose |
|---|---|
safe_delete |
trash-based delete (preview / force / undoable) |
safety_trash / safety_undo |
list trash / restore an item |
safety_snapshot / safety_restore |
snapshot composition / rollback (confirm:true) |
safety_check |
pre-restart validation (UTF-8 / mojibake / JSON / duplicate ids) |
safety_journal / safety_status |
audit log / state |
Configuration
Configure via the bundle row in a patch layer (e.g. the profile's
cordis.patch.yml):
- id: dsh-safety
config:
blockWriteRoots: ["C:\\extra\\protected"]
confirmDeleteRoots: ["D:\\data"]
snapshotExclude: ["settings.yaml", ".credentials.yaml"]
blockWrites: true
blockShellDestructive: true
audit: true
keepTrash: 200
keepSnapshots: 10
| Field | Default | Meaning |
|---|---|---|
blockWriteRoots |
profile manifests/patches/lockfiles/node_modules, install dir, home patch/settings | no write/edit/delete |
confirmDeleteRoots |
$HOME, profiles/*, .agent-presets |
no delete without force (still trash-only) |
snapshotExclude |
["settings.yaml", ".credentials.yaml"] |
files never copied into snapshots |
blockWrites |
true |
enable the write/edit guard |
blockShellDestructive |
true |
enable the shell-delete guard |
audit |
true |
journal destructive tool calls |
keepTrash / keepSnapshots |
200 / 10 |
retention limits |
How it works
Three-tier policy:
| Tier | Allowed | Denied | Default coverage |
|---|---|---|---|
protected |
read | write / edit / delete | profile package.json/cordis.patch.yml/cordis.yml/lockfiles/node_modules, install dir, home patch & settings |
confirm |
read, edit | delete (needs safe_delete --force, still trash-only) |
entire $HOME, plugin sources, agent presets |
free |
read/write/delete | recursive delete | regular workspace files |
The guard decision chain, per tool call: destructive verb? → is it a
recursive delete? → does an explicit path hit a protected/confirm zone? → does
a variable-reference fragment ($env:X\…, %X%\…, ${X}/…) expand into a
protected zone? → does the command text hit a protected marker (~/relative
forms)? → run_code code bodies go through the same chain → recursive deletes
are denied everywhere as a final rule. Denials are journaled and returned
to the model as errors (never a crash).
A second layer hooks the fs/write-intent / fs/edit-intent waterfalls and
throws FS_DENIED on protected paths regardless of which tool writes.
buildPolicy lives in safety-core.mjs and is shared by the plugin guard and
the standalone CLI, so the two surfaces can never drift apart.
restoreSnapshot is transactional: it backs up live files first, then copies
snapshot files back, and rolls the whole thing back if either phase fails — a
failed rollback never leaves the composition half-restored.
Structure
dsh-safety/
├── bin/
│ └── dsh-safety.mjs # standalone CLI (zero deps)
├── lib/
│ ├── safety-core.mjs # pure logic: policy/guard/trash/snapshot/check
│ ├── index.js # host half: tools, guard, fs hooks, web route
│ └── client.js # browser half: "Safety Center" settings panel
├── test/
│ ├── safety.test.mjs # 20 unit tests (zero deps)
│ └── harness.mjs # 38 integration checks (clean checkout, zero deps)
├── cordis.patch.yml # bundle patch (inserts the dsh-safety row)
├── package.json # dsh.bundle + dsh.client + bin
├── install.ps1 / recover.ps1 # local convenience scripts (snapshot→install→verify→rollback)
├── README.md / README.zh.md # docs (bilingual, officially paired)
└── LICENSE / NOTICE / SECURITY.md
Testing
node --test test/safety.test.mjs # 20 unit tests, zero dependencies
node test/harness.mjs # 38 integration checks, clean checkout (no @deepseek-ai needed)
npm run check # syntax checks
Troubleshooting
- DSH won't boot after a plugin change: run
dsh-safety checkto find mojibake / JSON / duplicate-id problems;dsh --profile web --dump-default-configto see the bundle layer without the user layer;dsh-safety restore <id> --confirmto roll back a snapshot. - The guard blocks something legitimate: the guard never blocks reads or
edits of plugin sources; it blocks deletes on
$HOME/plugin/config zones — usesafe_delete(undoable) instead of rawrm. - A protected path needs to be deleted:
safe_deletewithforce:true(ordsh-safety delete --force) — the item still goes to trash, never permanent.
Security
See SECURITY.md. In short: the guard intercepts model tool
calls, not commands you run in your own terminal; safety_check is a
line-level scanner, not a full YAML parser. It is a safety net, not a sandbox
— configure DSH's own sandbox/approval for real containment, and use this
plugin for the recovery layer DSH lacks.
License
MIT. Integration patterns modeled after DeepSeek Harness (MIT); see NOTICE.
还没有评论,来写第一条。