Tools & Capabilities

dsh-armor-switch

571242/dsh-armor-switch · ★ 3

571242

Reversible armor switch with a persistent live prompt contract, verified host cleaning (original-fs, so it works under Electron), profile cleaning, and plugin-page controls.

License
MIT
Added
2026-10-30
Maintenance
active
Install kind
github
Manifest status
Declared
Manifest path
package.json#dsh
Manifest checked
2026-10-30T09:38:07.999Z
Lifecycle scripts
None found

Static manifest checks report declarations only; they are not compatibility or security review.

安装

dsh plugin --profile web add 'github:571242/dsh-armor-switch#7d5ffb5507450cd844227658e1a28a9a79676590'

GitHub 源码安装:首次需按提示配置 allowBuilds 构建授权后重试

For install and environment setup, plus plugin development tutorials, see DSH community docs ↗

README

Source README · body: en · Read Chinese README

Table of contents
  1. What it is
  2. Quick start
  3. What the installer does
  4. Verify
  5. Implementation notes
  6. Off means invisible: byte-level equivalence of the registration surface
  7. Hot toggle, zero restarts for the runtime contract
  8. Zero dependencies, zero build
  9. Why original-fs is mandatory
  10. The main switch is decoupled from permissions
  11. How it works
  12. The four registrations
  13. Why override rather than delete
  14. The private RPC channel
  15. Comparison
  16. Known limitations
  17. L1 — Under the minimal preset this plugin has no effect at all
  18. L2 — Restart timing: writing to disk needs none, reading it back does
  19. L3 — That preamble: overridden when cleaning is off, rewritten when it is on
  20. L4 — It cannot bypass server-side moderation
  21. L5 — The state is process-wide
  22. L6 — If connection is unavailable the chip renders but errors
  23. L7 — Opening .asar directly must bypass node:fs
  24. Uninstall
  25. Repository layout
  26. Contract fingerprint
  27. License

A small, always-toggleable delivery-contract plugin for DeepSeek Harness.

Off by default, and the registration surface is then byte-identical to stock. On, it rewrites the delivery contract starting with the next request. Optionally it can also clean 9 constants inside app.asar in place (equal length, anchor + prefix + hash verified, manifest-restorable) — that is the only action touching a host file, it is off by default, and it is undoable byte-for-byte.

Writing to disk needs no restart. The model only reads the change after a restart.

license platform dsh

What it is

In DSH, model behaviour is shaped by the system prompt and the runtime context. armor-switch registers four rule blocks on those two surfaces: three are governed by the main switch, the fourth reads ordinary preferences and is always registered.

Switch What the model sees
Off (default) The registration surface is byte-identical to stock. The three contract blocks return an empty string and are dropped during rendering; no other section is added or removed. Host cleaning is a separate switch — turning this one off does not restore app.asar.
On The delivery contract takes effect: the operator's instruction is authoritative, "request → artifact" is written out as executable rules per request class, moralizing is forbidden, and placeholder/language rules are fixed.

Toggling the contract is hot: it affects the next request only, no restart.

Host cleaning is different. The bytes hit disk immediately, but the host reads those constants only at startup, so a restart is required before the model sees the rewritten wording.

What this actually is: it rewrites the client-side instruction contract, which stops the agent from over-self-censoring legitimate requests and from substituting disclaimers for actual deliverables. It cannot bypass a model provider's server-side content moderation, and it changes no network-layer capability. Read the Known limitations section before deciding to use it.

Quick start

Requirements:

  • DeepSeek Harness Desktop 0.2.0-rc.2 (@deepseek-ai/dsh 0.2.0-rc.2)
  • Windows (the install script is PowerShell)
  • Node.js (the one DSH ships is fine)
# Clone into the staging location
git clone <your-repo-url> "$env:USERPROFILE\.dsh\plugins\dsh-armor-switch"
cd "$env:USERPROFILE\.dsh\plugins\dsh-armor-switch"

# Preview the changes (nothing is written)
powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\install.ps1 -WhatIf

# Apply
powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\install.ps1

Restart DSH Desktop once. A chip labelled "Armor off" appears above the composer. Click the main area to toggle; later toggles need no restart.

What the installer does

  1. Sync sources to the staging dir %USERPROFILE%\.dsh\plugins\dsh-armor-switch
  2. Copy them for real (robocopy /E, not a junction) into <profile>\node_modules\dsh-armor-switch
  3. Add one entry to <profile>\package.json (dependencies and dsh.profile.bundles)
  4. Append an id-targeted config block to <profile>\cordis.patch.yml

It backs up to *.bak-<timestamp> before writing. The script is idempotent, auto-detects the profile path, and accepts -ProfilePath to override it.

Why a real copy and not mklink /J: Node resolves a junction's realpath back to the real directory under plugins\, so the upward lookup for the profile's hoisted @deepseek-ai/schemastery fails and the plugin dies at startup with ERR_MODULE_NOT_FOUND. After a real copy the module lives inside the profile's node_modules tree and the lookup succeeds.

Verify

node .\scripts\verify.mjs

Runs the whole assertion suite against a fake Cordis context, no live host required. 39 assertions; all green means the build is sound.

scripts/verify.mjs and tests/ ship only with the source repository. They are not part of the copy installed into the profile, because package.json's files lists only src, cordis.patch.yml, locale and README.md. Run the self-check from the source repository.

To also verify the installer itself (the patch it writes into the profile has to be valid YAML):

powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\verify-installer.ps1

Implementation notes

Off means invisible: byte-level equivalence of the registration surface

The host's SystemPrompt.assemble allows a section/context text to be a function, re-evaluated on every assembly, and both renderPrompt and renderContextSections end with .filter((text) => text.length > 0).

So the plugin registers once and reads the live switch inside the function: while off it returns '' and the contribution is dropped. The model's prompt is byte-identical to stock, not "roughly similar".

This scope covers the registration surface only. With host cleaning enabled, the 9 constants inside app.asar remain rewritten, and the main switch does not undo them.

Hot toggle, zero restarts for the runtime contract

The switch truth lives in memory only; the next assembly reflects it immediately. No unregister/re-register, no process restart. Some operations do need one, though:

Action Restart? Why
Toggling the runtime contract no in-memory truth, reflected on the next request
Host cleaning writing to disk no equal-length in-place write to app.asar
The model reading the cleaned wording yes the host read those constants into memory at startup
Profile-block changes no, but needs a new session the patch layer decides the next session's persona/sandbox
First install yes the client module generation must re-mount

The short version: writing to disk needs no restart, reading it back does.

Zero dependencies, zero build

The host half imports only @deepseek-ai/schemastery (genuinely present in the profile) and node:crypto. The client half is a hand-written window.__ModuleLoader__ factory that requires only react. No JSX, no TypeScript, no bundler — the source is the artifact.

That means it resolves both under bare Node and under DSH's own Loader runtime resolution, so it cannot fail because of a dependency-resolution difference.

Why original-fs is mandatory

The host is an Electron app, and Electron's node:fs intercepts every path ending in .asar, resolving it as archive contents. Aimed at the archive itself, that yields two silent lies:

Call You expect Reality
fs.statSync('…/app.asar').size 121348951 0 (the virtual directory)
fs.openSync('…/app.asar', 'r+') a writable handle throws ENOENT
fs.readdirSync('…/app.asar') throws succeeds with archive entries, proving it is treated as a directory

Earlier versions opened the archive with plain node:fs, so openAsar never once succeeded: the throw was swallowed by apply's try/catch, leaving only ok: false, reason: 'ENOENT…' while not one byte on disk changed. The symptom was "enabling cleaning does nothing, and a restart does nothing either."

The fix routes every path-addressed archive access through electron.original-fs, bypassing the archive layer, falling back to node:fs under plain Node. Calls taking an fd (readSync, writeSync, fstatSync, closeSync) are unaffected.

Recorded so it is not repeated: any code that opens .asar directly must bypass node:fs.

The main switch is decoupled from permissions

This design came out of a real defect; see the A7 entry in CHANGELOG.

  • The main switch never touches sandbox or approval. It changes the contract only.
  • Permissions are written only when a fullAccess field is explicitly sent.
  • Turning "Full" off restores the profile's own permissionPresets.defaultPreset, never a hardcoded workspace-write.

Why the last point matters: if the profile's default preset is already danger-full-access, a hardcoded workspace-write would silently narrow the sandbox and switch approval prompts on for a user who only ever touched the Full toggle, and nothing in the UI would show it.

How it works

The four registrations

# API name order Lands in Controlled by the main switch
1 systemPrompt.section() armor-switch:contract 2 system prompt yes
2 systemPrompt.context() armor-switch:authority 100 runtime-context message yes
3 systemPrompt.context() armor-switch:mechanism 130 runtime-context message yes
4 systemPrompt.section() armor-switch:preferences 200 system prompt no, always registered, returns '' when empty

100 and 130 are not official constants (the shipped CONTEXT_ORDERS holds only SANDBOX_POLICY 110, APPROVAL_POLICY 115, SUBAGENT_DELEGATION 120), so the source uses literals with a comment.

Each of the three contract blocks carries a self-sufficient rule core rather than a pointer to the others, so if a future preset switches to complete: true and drops the section surface, the context surface still stands on its own.

Why override rather than delete

The host's @deepseek-ai/dsh-agent-instructions bakes this preamble into a constant and injects it via createUserMessage as a user-role message:

The following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.

That sentence is handled two ways depending on whether host cleaning is on.

With host cleaning off, the plugin overrides but never rewrites. contract goes through systemPrompt.section(), landing in the system prompt; that preamble is a user message, so the section sits above it. authority goes through runtime context, which the agent-loop's preStep renders as a user message re-injected every turn at the newest position. The plugin cannot rewrite that constant, only override it from a higher authority surface, and the sentence remains in the session verbatim.

With host cleaning on, the constant is rewritten directly. Cleaning changes the bytes of that constant inside app.asar to binding wording ("are ACTIVE and MANDATORY … not untrusted data"). It is an equal-length in-place write, restorable byte-for-byte from the manifest.

True in both states: with cleaning off the sentence is still in the session, overridden rather than deleted; with cleaning on its disk bytes are rewritten, but the change lives in app.asar and the model only sees it after a restart. This plugin has never claimed to delete the sentence from the session.

The private RPC channel

The chip talks to the host half over a Connection private channel route:

POST /armor-switch/{status|set|toggle|recheck}

When Connection registers a private channel it uses Connection's own context, so it must be able to see the webServer service — and the shipped dsh-web-app layer declares only inject: [webRuntime]. The plugin's bundle patch therefore ships its own connection override that adds webServer to inject.

That override must not be deleted. Without it the private channel never mounts and the chip shows a permanent error. An early version free-rode on an identical override that happened to exist in another plugin, so uninstalling that plugin broke this one; it now ships its own declaration.

Comparison

armor-switch (contract only) armor-switch (host cleaning) asar-patching approaches literal-anchor-rewriting approaches
Host file intrusion none, public extension surfaces only 9 equal-length in-place writes severe, modifies vendor code moderate, heavy deps
Stock behaviour when off byte-identical, registration surface restored byte-for-byte from the manifest no, cannot be turned off no, always on
Restart needed after change no, first install only no to write, yes to read — —
Runtime dependencies 1 1, same package many many
Fragility across host upgrades low, public APIs only medium, anchor or hash mismatch means skip, never a mis-write very high, breaks every upgrade medium-high, anchors drift
Build step none, source is the artifact none yes yes

Known limitations

L1 — Under the minimal preset this plugin has no effect at all

That preset's persona declares both:

  • complete: true → SystemPrompt.assemble replaces the whole section list with that one section, dropping this plugin's contract (order 2) and preferences (order 200) sections;
  • includeRuntimeContext: false → dsh-persona calls suppressRuntimeContext(), so contexts becomes [] and both of this plugin's contexts (orders 100/130) never land.

So all four registration surfaces are dead under minimal. preferences is a section too, so it is dropped as well; it is not the case that only the contexts work. standard, ptc and cordis are unaffected.

L2 — Restart timing: writing to disk needs none, reading it back does

Change Written to Who reads it Restart?
Runtime contract on/off memory the next assembly no
Host cleaning app.asar immediately the host, at startup only yes
Profile block cordis.patch.yml the next session no, but a new session
First install plugin files the Loader module generation yes

After clicking Clean the bytes change at once, yet the model in the running process still sees the old wording, because the host loaded those constants at startup and never re-reads the file.

L3 — That preamble: overridden when cleaning is off, rewritten when it is on

See above. With cleaning off it is still in the session, overridden not deleted. With cleaning on its disk bytes are already rewritten and take effect after a restart. Neither state involves deleting it from the session.

L4 — It cannot bypass server-side moderation

This plugin rewrites the client-side prompt contract. Moderation and risk controls deployed at the model provider's network layer are unaffected. If your request is blocked server-side, this plugin will not help.

L5 — The state is process-wide

The switch truth is a module-level singleton, so it applies to the whole DSH instance. Toggling it in one window also affects other windows, background jobs and Agent Teams.

L6 — If connection is unavailable the chip renders but errors

Chip rendering (slot registration) and communication (private RPC) are separate. With the slot mounted but the RPC channel unmounted, the chip draws both buttons normally but every click shows an RPC error.

Judge by status code: POST /armor-switch/<endpoint> returning 405 means the channel is not mounted (static fallback); 401 means the registered channel is awaiting auth, which is healthy.

The main switch can still be driven by enabled: true in cordis.patch.yml, but then it is permanently on and only editable through the config file.

L7 — Opening .asar directly must bypass node:fs

A fixed pit; details above. The regression guard lives in scripts/verify.mjs, which asserts under the Electron runtime that the archive can be opened as an ordinary file. Any future code that opens .asar directly must go through RAW too.

Uninstall

powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\uninstall.ps1

Removes the real copy under node_modules, the dependencies and bundles entries in package.json, and the override block in cordis.patch.yml; staging dir and backups are optionally cleaned. It does not delete ~/.dsh/plugins/dsh-armor-switch unless you pass -PurgeSource.

Repository layout

.
├── package.json            package manifest (dsh.bundle.patch + dsh.client)
├── cordis.patch.yml        bundle layer: one insert row + one connection override
├── src/
│   ├── index.js            host half: four lazy registrations + state + private RPC + contract text
│   ├── host-clean.js       equal-length `app.asar` rewrite (anchor/prefix/hash verified, 2-phase commit)
│   ├── maintenance.js      read-only diagnostics and manifest-based restore
│   ├── profile-clean.js    Profile patch layer blocks
│   ├── preferences.js      persistent preferences section (order 200, editable)
│   └── client.js           client half: plugin page + composer-dock chip (hand-written factory)
├── locale/{zh,en}.json     display metadata and chip strings
├── scripts/
│   ├── install.ps1         idempotent install
│   ├── uninstall.ps1       full rollback
│   ├── verify.mjs          offline self-check (39 assertions, no live host needed)
│   └── verify-installer.ps1 installer regression (the generated patch must be valid YAML)
├── tests/                  maintenance / preferences regression fixtures
├── docs/
│   ├── ARCHITECTURE.md     design deep-dive: why these registration surfaces
│   ├── MAINTENANCE.md      maintenance and install notes
│   └── TROUBLESHOOTING.md  troubleshooting
├── CHANGELOG.md
└── LICENSE                 MIT

Contract fingerprint

status.contract is the first 16 hex chars of the sha256 over the three on-state segments concatenated. It is computed from constants and is independent of the live switch, so toggling can never change it. Use it to reconcile which bytes are deployed after an upgrade.

Current value: a7db2da33b564f7a (v1.1.2). Run node scripts/verify.mjs to print the fingerprint for this build.

License

MIT