dsh-edit-approval
Per-edit approval for DeepSeek Harness: every write / edit / str_replace_editor call asks before the file is touched — a red/green line-level diff, then approve once or reject; master switch in Settings → General.
English | 中文
✨ Features
| Feature | Description |
|---|---|
| Pre-write approval | Intercepts write / edit / str_replace_editor on the tools/pre-execute seam and asks before any file is modified |
| Red/green line diff | Line-level diff (added / removed / context) computed per tool semantics; the approval panel shows only the changed lines — a right-aligned line-number gutter with ` |
| Panel collapse | Long diffs can be collapsed via the button at the strip's right end to reveal the agent's output; CSS-only hide/show, expanding restores the exact view |
| Approve once / reject | Two actions, mirroring the Claude Code edit-approval flow; rejection reports back to the model |
| Master switch | Settings → General "Edit approval" row, backed by the /approval-edit on|off|status host command (same source) |
| Policy-aware | Respects the session approval policy: ask intercepts, never (full access) runs edits through untouched |
| Thresholds | minDiffLines, includeCreate, includeDelete for fine-grained control |
📸 Screenshots
![]() Master switch in Settings → General |
![]() The /approval-edit command and its arguments |
![]() Approval panel — red/green line diff |
📦 Install
Published to npm — the registry path is the recommended one. Restart dsh web (--profile web) after installing.
dsh plugin --profile web add dsh-edit-approval
For contributors: local checkout (dsh plugin --profile web add /path/to/dsh-edit-approval), a pinned GitHub commit (dsh plugin --profile web add github:SiriLee/dsh-edit-approval#<sha>), or an offline tarball (npm pack then dsh plugin --profile web add ./dsh-edit-approval-<version>.tgz). A git install fails on first run until you add an allowBuilds key to the profile's pnpm-workspace.yaml (pnpm blocks git dependencies from running build scripts); after that it runs the plugin's prepare and installs it. npm pack also runs prepare, so a tarball always carries a prebuilt lib/ (with .d.ts) and the LICENSE.
How it works
The plugin listens on the tools/pre-execute waterfall (the seam the harness
runs before a tool executes) and matches a whitelist of registered tool names:
write, edit, str_replace_editor. For each intercepted call it:
- Resolves the target through
ctx.fs, applying the same session-cwd rule the fs tools use (a relative..path canonicalizes the cwd). - Reads the current content and reconstructs the proposed content from the
tool's arguments, mirroring each tool's semantics:
write— full text;edit— single unique replace (orreplace_all);str_replace_editor—str_replaceunique replace,insertline insertion,createusesfile_text.
- Computes a line-level diff between current and proposed content with
jsdiff's
structuredPatch(Myers), the same reference implementation and the same 3-line context window the harness's write/edit result cards use — so the approval preview and the post-approval result card derive from the same algorithm, and a one-line edit in a large file stays a one-line diff. - Returns
{ kind: 'ask', reason }with a header line (tool · file (op): N insertions, M deletions) plus the diff text — change rows with a right-aligned line-number gutter and|(15| -xi/15| +XI-EDITED), plus context rows and⋯hunk gaps carried on the same structuredPatch source as the harness result cards. The harness's ownserviceAskroutes that throughctx.approvalinto the web approval panel — the host needs zero UI changes.allowed-onceproceeds,rejecteddenies the call; every other case delegates vianext().
The browser half (dsh.client) rebuilds the panel's plain-text headline into
only the changed rows — removals red, additions green, with the right-aligned
NN| gutter — and a … ellipsis marks skipped context runs and hunk gaps;
it adds a white-space: pre-wrap compensation for the headline's CSS,
installs the collapse button on multi-line diffs, and registers the
Settings → General master-switch row. All side effects live in a single
ctx.effect (torn down on plugin unload / HMR), and a per-animation-frame
MutationObserver enhances approval panels as they appear.
Approval policy interaction
The harness's session approval policy (ask / never) keeps applying:
| Session policy | Plugin behavior |
|---|---|
ask (e.g. workspace-write preset) |
Intercepts and shows the approval panel |
never (e.g. danger-full-access preset) |
Delegates — edits run without prompting, the sandbox keeps enforcing |
Under never, every ask this plugin emitted would be deterministically
rejected by the approval service, silently breaking every edit in a full-access
session. The plugin therefore stops asking and lets the sandbox enforce. It
never expands access or changes the sandbox mode.
Configure
Runtime configuration lives in the edit-approval settings namespace, layered
as schema defaults < cordis row config < user settings page (persisted).
The cordis row ships without config on purpose; a profile patch overrides
deployment defaults by restating only the keys it changes:
# profile's cordis.patch.yml
- id: dsh-edit-approval
name: dsh-edit-approval
config:
minDiffLines: 2
includeCreate: false
| Key | Default | Description |
|---|---|---|
enabled |
true |
Master switch (users can turn it off) |
tools |
['write','edit','str_replace_editor'] |
Whitelist of intercepted registered tool names |
minDiffLines |
0 |
Ask only when the change touches at least this many lines; smaller changes pass silently |
includeCreate |
true |
Whether creating a new file asks for approval |
includeDelete |
true |
Whether clearing/emptying a file asks for approval |
Behavior details & limitations
- Only write-family tools are intercepted; edits inside
bash/pwshcommands are out of scope. - The diff is presented as
+/-line markers — a read-only preview, not interactive per-line selection; "apply partially" is not supported. - Cases the tool itself would fail on are not asked about and pass through
for the tool to report:
str_replace_editor createagainst an existing file, a non-unique or missingold_str/old_string. An emptyold_stringeditpreview deviates from the tool (treated as not-found) — the deviation is safe, it never falsely blocks. - The button text follows
navigator.language, notctx.locale— a deliberate simplification for a self-contained bundle. - Note the registered tool name is
str_replace_editor(underscores), distinct from the npm package name@deepseek-ai/dsh-tool-str-replace-editor.
Not included
- Post-edit review / rollback — covered by the community dsh-change-review.
- Keyboard shortcuts (Enter to approve / Esc to reject) — split into a dedicated plugin.
- Permission-tier extensions — covered by the community dsh-auto-approval-plugin.
Compatibility
- Node.js
^22.19.0 || >=24.0.0. - DeepSeek Harness web profile (
dsh --profile web); peer@deepseek-ai/*packages are resolved by the harness at runtime.
[!WARNING] This project and DSH are both in developer preview. Pin exact versions in reproducible environments and review the behavior notes above.
Development
npm install # devDeps from the npm registry
npm run typecheck # tsc on both compilation surfaces (host + client)
npm test # vitest: diff / guard unit tests + real-cordis integration + jsdom panel-collapse (54 cases)
npm run build # full build: tsc → lib/ (with .d.ts) + lib/client.js bundle
npm run build:portable # optional lightweight esbuild build, no typecheck
node scripts/verify-host.mjs # verify the BUILT host artifact end-to-end
prepare runs the full build, so git installs and npm pack/npm publish
always produce a complete lib/ (with .d.ts) and the LICENSE.
Publishing
Releases go out through GitHub Actions Trusted Publishing (OIDC, no stored
NPM_TOKEN). See docs/npm-trusted-publishing-guide.md.
npm version patch && git push origin main --tags # triggers .github/workflows/publish.yml
The workflow verifies the tag matches package.json, runs typecheck + tests +
a full build + artifact verification, publishes with Sigstore provenance, and
creates a GitHub Release. CI (.github/workflows/ci.yml) runs the same checks
on every push / PR. The publish step is idempotent — a version already on npm
is skipped.
Directory layout
src/index.ts host plugin: tools/pre-execute interception + /approval-edit command + settings
src/diff.ts line-level diff (pure functions: jsdiff structuredPatch mapping, render, counts)
src/guard.ts decision logic (pure functions: tool matching, thresholds, create/delete, ask/pass)
src/client/index.ts client plugin: red/green diff rendering + master switch + lifecycle
src/client/settings-row.tsx Settings → General toggle row
tests/ vitest suites (diff / guard / integration / client collapse)
scripts/ build + artifact verification
cordis.patch.yml bundle patch (mounts the host plugin row)
package.json dsh.bundle + dsh.client manifests, peerDependencies
Security
This plugin reads the target file only to compute the preview diff (at the tools/pre-execute interception point); it never writes files itself — the tool body performs the write only after you approve. It makes no network requests and accesses no credentials.


