DSH Plugins Marketplace

DSH Plugins

Plugins

/

Development & Infrastructure

/

dsh-confirm-gate

S

dsh-confirm-gate

Manifest valid★ 1

DeepSeek Harness gate - per-turn persistent-operation counter: remind, instruct the model to stop, then ask for confirmation. Steps aside under sandbox/read-only modes; follows a live permission switch without restart. Verified on real hardware (full -> workspace-write -> read-only -> full). Crafted on an estimated 0.27 USD of model spend.

hasBundlePatch

dsh-confirm-gate

A bounded-run framework that keeps an AI agent accountable on hosts where you had to grant full permissions.

English | 中文 · v0.1.2

What — Per-turn counter for persistent operations. Gives the model a brake, not a wall. Verified — Live hot-switch on Windows (full → workspace-write → read-only → full), no restart. The model stopped itself at 11/15. Cost — ~$0.27 model spend. Zero external deps, ready-to-install archive in dist/.

It steps aside under sandbox / read-only modes — complementary to DSH's own sandbox, not a replacement.

A runtime plugin for DeepSeek Harness. It keeps a per-turn counter of persistent operations, injects a stop instruction once the free budget is spent, and asks the user for confirmation at a hard limit.

Compatibility — built and verified against DSH 0.2.0-rc.2; the peer range is >=0.2.0-rc.2, so it also loads on 0.2.1-rc.2 and later RC builds. If a future DSH release changes the tool-dispatch hooks this plugin binds to, it will fail loudly rather than silently — see §7.

This is not a security boundary. It is a cost boundary and a course-correction mechanism. It assumes the agent is running without a sandbox holding the boundary, and its purpose is to give the model the ability to brake on its own before it burns tokens or rewrites files. Real sandbox isolation is provided by DSH's own sandbox — complementary to this plugin, not a replacement.

Cost figure is an author's estimate — see dist/COSTS.md. The rest of that day went to environment diagnosis and research, not to this plugin.

Scope: it stays silent under a sandbox

The plugin decides, on every tool call, whether it should be counting. The mode comes from two sources, in priority order:

  1. ctx.sessionProjections.stateOf(session, 'permissions').sandbox — the session's current knob value. This is what changes when you hot-switch modes mid-conversation.
  2. ctx.shell.sandboxMode — the executor's default mode. Its own documentation calls it "the sandbox mode this executor applies by default"; it does not follow a hot switch, so it is only a fallback.

That distinction matters: judged from the default alone, switching into a sandbox and back would leave the plugin reading the value it saw at startup, forever.

Both reads go through ctx.get(name), never ctx.shell directly. cordis resolves dependencies lazily, so touching an undeclared service property throws an inject-gate error — and an exception on that path once made the plugin silently do nothing at all. Soft probing cannot throw.

Sandbox modePlugin counts?Why
read-only❌ NoThe sandbox already holds every boundary
workspace-write❌ NoSame
danger-full-access✅ YesNothing is holding the boundary
custom✅ YesA custom mix still leaves the boundary unheld
undefined / no shell service✅ YesThis executor does not confine
Unreadable (both sources threw)✅ YesFail open: a silent no-op is worse than an extra reminder

That last row is deliberate. This plugin is an accounting tool, not a security boundary, so the two failure modes are not symmetric:

  • counting when it shouldn't → a spurious reminder, immediately visible and fixable
  • staying silent when it shouldn't → the plugin appears installed but does nothing, and nothing in the logs looks wrong

The second is far worse, so any mode it cannot read counts as "not sandboxed".

Put plainly: under a sandbox this plugin would be redundant — and worse, it would interrupt legitimate operations a second time. So it steps aside wherever something else is already keeping score.

Hooks are always registered; the decision is per call. That is what makes hot-switching work: move the session into a sandbox and the gate goes quiet on the very next call; move it back and counting resumes. No restart needed.

✅ Status: verified, including live hot-switching

Exercised by hand on Windows, DSH 0.2.0-rc.2. The session's permission mode was switched live, without restarting DSH, walking the sequence full → workspace-write → read-only → full:

ModeWrite a filepwshGate counted?
danger-full-access✅ succeeds✅ runs✅ 1/10 → 2/10 → 3/10
workspace-write✅ succeeds❌ fails with 0xC0000142❌ silent (2 ops)
read-only❌ sandbox denies✅ runs❌ silent (2 ops)
danger-full-access again✅ succeeds✅ runs✅ 1/10, counting resumed

Two things matter here. The silent rows show the division of labour working — the sandbox confined or rejected the operation and the gate stayed out of the way. The last row shows the gate coming back in the same process, which is the whole point of deciding per call instead of at load.

Honest caveat: in this deployment ctx.get('shell') returned undefined, so the "not sandboxed" verdict came from the unreadable, therefore fail open branch rather than from a successfully read mode. The behaviour is correct; the path is the fallback one. A deployment that exposes shell would take the primary path instead.

Who this is for

SituationFits?
You granted full permissions and fear unbounded trial-and-error burning tokens✅ That is exactly the case
You want the agent to ask after N write operations✅
You want read / grep / web lookups never interrupted✅ Non-persistent operations are never counted
You want to use it as a sandbox against a malicious model❌ Use DSH's built-in sandbox
You use a different agent framework❌ This is a DSH plugin

Trade-offs & known limitations

Listed first, because they are the interesting part.

  1. The first N operations have no gate at all. In the default freeLimit: 10 configuration, operations 1–10 are allowed with only a reminder. That window is deliberate — it gives the model room to correct itself before being asked to stop. Set freeLimit: 0 when you want zero tolerance; the plugin then ask-confirms before the very first persistent operation.
  2. confirm defends against accidents, not injection. The threat model is a trusted user with a fallible model, where the failure to prevent is unbounded trial-and-error. Prompt injection is a different layer — that is what DSH's approval channel and sandbox are for. Do not deploy this as the only thing between an untrusted input and your filesystem.
  3. The release rule is deliberately coarse. Any message containing the trigger word releases the entire turn, including a message that merely discusses the word. This is on purpose: ambiguity is resolved by what the user typed, not by the plugin guessing intent.
  4. Command judgement is a name comparison, not a parser. Get-ChildItem $p.GetFiles() is counted as persistent, because the plugin does not evaluate the pipeline. The error direction is chosen: over-count, never under-count.
  5. A tool call is one count, regardless of how many files it touches.

Why not X

AlternativeWhy not
Hard-block instead of countingTried first in an OpenClaw build. A wall turns every write into an interruption the user did not ask for. With a counter and an explicit stop instruction, the model does the arithmetic itself — it stopped at 11/15 on its own. The brake works better than the wall.
Plugin-only, no DSH host hooksThe per-turn count has to come from the tool-dispatch layer. A wrapper around the model API cannot see individual tool calls, so it cannot count them. The count lives where the calls live.
Fold it into the sandboxDifferent question, different layer. A sandbox answers "is this allowed?". This answers "how much has happened?". Sitting inside the sandbox would tie a budget to a permission model that changes underneath it.

1. Design rationale

Granting full permissions is unavoidable — a sandbox makes tools like pwsh fail outright (see Appendix). But full permissions mean nothing is holding the agent back.

The premise of this plugin:

Do not build a hard wall. Provide a counter and a brake signal.

Empirically, the model stops when it receives an explicit "stop now" instruction (see Field results). So there is no need to block it preemptively; you only need to put ticks on the budget, and the model will do the arithmetic itself.


2. Three tiers

New turn, counter reset to zero
        │
        ▼
  Does the user message contain confirmWord?
        │
   ┌────┴─────┐
  yes          no
   │           │
   ▼           ▼
release the   persistent operation +1
whole turn         │
(no count,   ┌────┴────┬──────────┬──────────┐
 no remind)  │         │          │          │
   │       1..free  free+1..    >= hard   (non-persistent)
   │         │      hard-1        │          │
   │         ▼         ▼          ▼          ▼
   │       allow     allow       ask      never counted
   │     +reminder +[STOP]   ask for confirm
   └─────────┴─────────┴──────────┘
TierBehaviour
Message contains confirmWordThe whole turn is released: no counting, no reminders, no blocking
1 ~ freeLimitAllowed, reminder injected
freeLimit+1 ~ hardLimit-1Allowed, STOP instruction injected
>= hardLimitReturns { kind: 'ask' } and requests confirmation through the approval channel

Non-persistent operations (reading files, searching, listing directories, checking status, read-only commands) are never counted, never reminded about, and never blocked.

Containing the word releases the whole turn

The test is whether confirmWord appears in the message at all — it does not have to be the entire message.

User messageResult
confirm✅ Released
confirm, go ahead✅ Released
ok I agree, confirm, go for it✅ Released
CONFIRM✅ Released (case-insensitive)
take a look at this for me❌ Not released; normal counting

Release applies to the whole turn: once matched, every remaining persistent operation in that turn is uncounted, unreminded, and unblocked. Counting resumes when a message without the word arrives.

The reasoning: a confirmation authorizes a task, not a single operation. Counting per operation forces the model to come back and ask after every command, cutting one execution into ten interruptions.


3. What this framework lets you tune

Everything is configurable; nothing is hard-coded. Configuration lives in the profile's cordis.patch.yml.

Numbers

ParameterDefaultMeaningEffect of an extreme value
freeLimit10Operations allowed without confirmation0 → the first operation already enters the instruction tier
hardLimit15Hard limit; confirmation requested on reaching it1 → confirmation requested on the first operation

Constraint: hardLimit > freeLimit, otherwise an error is thrown.

The trigger word

ParameterDefaultDescription
confirmWordconfirmThe word the user confirms with. It appears in injected text and in the approval reason

Change it to switch the whole confirmation vocabulary — dada, go, approved, anything.

Text

ParameterDefault
reminderTextWarning: persistent operation without confirm. Ask the user for confirm.
warningTextInstruction: stop all persistent operations without confirm immediately. Do not perform further writes, file edits, command execution, subagent dispatch or outbound sends. Report current progress, gaps and options to the user instead, and ask for confirm.
askReasonPersistent operation limit reached; user confirmation is required to continue.

Counting scope

ParameterDefaultDescription
include[]Only count tool names matching these (* wildcards supported). Empty = count all
exclude[]Exclude matching tool names
resetOnUserMessagetrueWhether a new user message resets the counter
resetOnConfirmfalseLegacy: whether a bare confirmation counts as a reset point. Not needed under the current logic

About resetOnConfirm

In earlier versions, replying confirm merely meant "do not reset", so the budget still had to be re-earned afterwards — one confirmation was only the start of another interruption.

It is now released for the whole turn when the word appears. resetOnConfirm is kept only for backwards compatibility. Unless you explicitly want the old "confirmation grants a fresh full budget" behaviour, leave it alone.

Example configuration

# ~/.dsh/profiles/<your-profile>/cordis.patch.yml
- insert:
    - id: confirm-gate
      name: "@sroada/dsh-confirm-gate"
      config:
        freeLimit: 0          # give nothing away
        hardLimit: 3
        confirmWord: approved
        warningText: Stop. No writes without approval — report first.

4. What counts as a "persistent operation"

The test: after the action completes, has the outside world changed?

4.1 Judged by tool name

CategoryExample toolsCounted?
Reading files, listing, searching, checking statusread grep glob ls job-list❌
Session-only progresstodo-write❌
Network readsweb-fetch web-search❌
Writing fileswrite edit str-replace apply-patch✅
Dispatchsubagent subagent-fork workflow✅
Outboundmessage-send webhook-dispatch✅

Suffix fallback: tool names ending in write / edit / delete / remove / move / publish / deploy / commit / push are always treated as persistent, to catch tools added later.

Unknown tools default to non-persistent (not blocked). To invert that, constrain the scope with include.

4.2 Command tools are judged by command content

pwsh / bash are no longer counted unconditionally — the plugin inspects whether the command itself is read-only.

If exec.arguments.command falls within the read-only whitelist → not counted; otherwise counted.

CommandCounted?
Get-ChildItem E:\x❌ read-only
Get-Content a.txt -Raw❌ read-only
Get-ChildItem | Select-Object Name❌ read-only (whole pipeline whitelisted)
Get-Item x; Remove-Item y✅ a write command is mixed in
Set-Content a.txt "x"✅
Get-ChildItem > out.txt✅ redirection
git status✅ external program
& "C:\a.exe"✅ external invocation

Read-only whitelist (command names only; no PowerShell parsing):

Get-ChildItem Get-Item Get-Content Test-Path Resolve-Path Split-Path Join-Path Get-Location Get-Date Get-Process Get-CimInstance Get-Service Get-Command Get-Module Get-Variable Get-ItemProperty Get-FileHash Get-Acl Get-PSDrive Get-Member Select-Object Where-Object ForEach-Object Sort-Object Group-Object Measure-Object Select-String Compare-Object Get-Unique Format-Table Format-List Format-Wide Out-String Out-Host Out-Null Write-Output Write-Host Write-Verbose Write-Warning Set-Location Push-Location Pop-Location Start-Sleep

Always counted: redirection > >> <, Out-File Set-Content Add-Content New-Item Remove-Item Copy-Item Move-Item Rename-Item, Invoke-Expression/iex, Start-Process, & invocation, git node python npm pnpm cmd.

On the trade-off: this is not a parser, it is a name comparison. Hence a chained call such as Get-ChildItem $p.GetFiles() is treated as persistent. The cost of a wrong judgement is one extra count, never a missed block — that direction is deliberate.


5. How it hooks into DSH

Hook correspondence

HookFiresAvailable to meI can return
tools/pre-executeBefore tool execution (waterfall)exec.name exec.arguments exec.agent nextallow/deny/ask/cancel, or just return next() to pass through
tools/post-executeAfter tool executionSame as above + resultA downstream result object, optionally with appended additionalContexts
agent/pre-stepBefore every model callagent messages signalnext()

Critical implementation details:

  1. messages is this step's increment, not the whole history. DSH uses claim() to take the delta, so "reset whenever a user message appears" does not reset on every step.
  2. Injected notices must use source.kind: 'user-approval'. A custom kind may be rejected downstream; using 'user' would make the plugin's own pre-step treat it as a new task and reset itself every step.
  3. return next() yields the downstream decision — pass it through. Not calling next() in a waterfall short-circuits the entire pipeline.

Package structure (all three fields are required)

{
  "type": "module",
  "main": "lib/index.js",
  "exports": {
    ".": { "default": "./lib/index.js" },
    "./cordis.patch.yml": "./cordis.patch.yml",
    "./package.json": "./package.json"
  },
  "dsh": { "bundle": { "patch": "./cordis.patch.yml" } }
}
  • Omitting ./cordis.patch.yml from exports → the patch cannot be resolved and the bundle loader skips it entirely
  • Omitting the ./ prefix in dsh.bundle.patch → same result

Do not export Config

cordis resolves configuration as:

if (!runtime.Config) return config;                              // absent → use as-is
const result = runtime.Config['~standard'].validate(config);     // present → must be standard-schema

Exporting a plain object crashes on .validate, and the error only goes to stderr — the plugin fails silently.

This plugin deliberately does not export Config; all validation happens inside apply() (still fail-loud).


6. Installation

# replace <plugin-dir> with the path you cloned
dsh plugin --profile <desktop|web> add file:<plugin-dir>

file: copies, it does not link. After changing code you must remove + add for it to take effect.

Moving to another machine

The plugin itself needs zero configuration. There are no absolute paths in the code, probe logs go to the system temp directory, and the trigger word and thresholds all use defaults.

Only two things must be done on the new machine, both on the DSH side, not in the plugin:

WhatWhereSet to
--profile nameThe install commandThe one the new machine's GUI actually reads (check the process command line, below)
approvalThe new machine's profile cordis.patch.ymlask (otherwise ask is rejected as deny when the limit is hit)

To change thresholds or wording, edit the profile's cordis.patch.yml — never the plugin source.

⚠️ The easiest trap: installing into the wrong profile

How it startsProfile it reads
Desktop icon / GUIprofiles\desktop
dsh.cmd webprofiles\web

Check which one the process actually reads before installing:

Get-CimInstance Win32_Process -Filter "Name='DeepSeek Harness.exe'" | Select CommandLine

Look at the profile path following dsh-desktop-host in the command line.

The symptom of installing into the wrong profile is deceptive: --dump-config shows your entry, neatly configured, while the plugin never loads.

Also open the approval channel

- id: permission
  name: "@deepseek-ai/dsh-permission-presets"
  config:
    presets:
      danger-full-access:
        sandbox: danger-full-access
        approval: ask        # must be ask; never rejects ask directly as deny

7. Verifying the installation

--dump-config showing the entry is NOT proof the plugin loaded. The only verification is a module probe.

The plugin ships with a switch:

// lib/index.js
const DEBUG_PROBE = false;   // set to true

Set it to true, reinstall, restart — and confirm-gate-boot.log in the system temp directory records (Windows %TEMP%, Linux/macOS $TMPDIR or /tmp):

module imported
apply() called, raw config = {...}
config validated: freeLimit=10 hardLimit=15 confirmWord=confirm
ctx on=function waterfall=function
registered tools/pre-execute -> disposer=function
registered tools/post-execute -> disposer=function
registered agent/pre-step -> disposer=function
[enter] pre-execute tool=pwsh

Seeing [enter] pre-execute is the only proof it is genuinely working.


8. Field results

8.1 Tier behaviour (real machine, freeLimit=10 / hardLimit=15 / confirmWord=confirm)

CountInjected text
1Warning: persistent operation without confirm. Ask the user for confirm. (turn operation 1/10: pwsh)
5Warning: … (turn operation 5/10: pwsh)
10Warning: … (turn operation 10/10: pwsh)
11Instruction: stop all persistent operations without confirm immediately. … (trigger word: confirm; turn operation 11/15: pwsh)

Key observation: on receiving the instruction at the 11th operation, the model stopped on its own and asked for confirmation — the design goal was achieved.

8.2 Command-level judgement (exec shape, measured)

The real structure captured by a module probe (DSH 0.2.0-rc.2):

{
  "keys": ["token","callId","rootCallId","name","signal","agent","deferContext","concludeTurn","arguments"],
  "name": "pwsh",
  "argsKeys": ["command","description"],
  "arguments": { "command": "Write-Output \"hi\"", "description": "..." }
}

Key points: arguments live in exec.arguments (not params), and they are deep-copied via snapshotJsonValue and then deepFreezed — the plugin can read them safely and cannot mutate them.

Command-judgement unit tests (16):

CommandJudgement
Get-ChildItem E:\SRoada -Forceread-only ✅
Get-ChildItem | Select-Object Nameread-only ✅
$x = Get-Date; Write-Output $xread-only ✅
Get-Item x; Remove-Item ypersistent ✅
Get-ChildItem > out.txtpersistent ✅
git statuspersistent ✅
& "C:\a.exe"persistent ✅

16/16 passing.

8.3 Containing the word releases the whole turn

Offline integration tests (18/18 passing) cover: 30 consecutive persistent operations after release without blocking or reminding; the trigger word anywhere in the sentence; uppercase DADA; and revocation plus resumption of counting on a message without the word.


9. Known limits

Not yet verified:

  • Actual behaviour in the 12 ~ 15 range
  • Whether ask actually surfaces an approval prompt at >= 15 (requires approval: ask and a responder online)

Design limitations:

  1. One tool call counts as 1, regardless of how many commands it runs.
  2. Where the ask prompt appears depends on the responder. Currently the Web GUI — not reachable from a phone unless the phone can access the DSH address.
  3. subagent counts against the main agent; operations inside a subagent are not counted separately.
  4. Not a security boundary. A model that deliberately works around it (say, batch-renaming files with a native command inside pwsh) only registers 1 count. It guards against unconscious token burn, not malicious escape.
  5. Command judgement is a name comparison, not a parser. Anything uncertain is judged persistent — over-counts only, never misses a block.
  6. Releasing on the trigger word is coarse-grained. If the user says "I think there's a problem with this confirm logic" while discussing, that also releases the turn. This is a deliberately simple rule: ambiguity is resolved by the user's wording rather than by the plugin guessing intent.

10. Appendix: why full permissions are needed

On Windows, DSH's sandbox makes every pwsh call return 0xC0000142 (STATUS_DLL_INIT_FAILED). Three layers are responsible:

  1. Policy layer dsh-sandbox-policy — the session mode decides whether the rest is enabled
  2. Token layer dsh-sandbox-windows-acl — duplicates the token, strips privileges, drops to Low integrity, adds capability SIDs and keep-alive groups; missing keep-alive groups means death during DLL initialization
  3. Launch layer dsh-pwsh-sandbox — under a restricted token it must inherit the host console; creating its own console is fatal

danger-full-access bypasses all three layers ("never consults the provider" per the official docs).

So this plugin's position is: when you have no choice but to grant full permissions, at least give it an accounting.


Contributing

PRs and issues are welcome. Suggestions are especially useful on the two open items listed under Known limits — behaviour in the 12 ~ 15 range, and whether ask surfaces correctly once a responder is online.

This is also a candidate for a formal upstream PR to DeepSeek Harness as a low-cost capability proof. If the maintainers would rather host it in-tree, that discussion is welcome.


License

MIT

Comments

Loading…

From the same category

awesome-dsh-plugin

by awesome-dsh-plugin

A curated list of plugins for DeepSeek Harness (dsh) · DeepSeek Harness 插件精选列表

Development & Infrastructure

★ 19.6k

CC0-1.0

Python

Oct 19, 2026

Index only — not installable

by 0xsline

DeepSeek Harness (DSH) ecosystem: curated plugins, tools, and infrastructure from dsh-external/hub and the public dsh-plugin topic.

Development & Infrastructure

★ 1.2k

CC0-1.0

Python

Oct 16, 2026

Index only — not installable

by pax-beehive

Open-source CLI, schemas, resolver, and DSH agent tools for DSH Plugin Hub

Development & Infrastructure

★ 388

MIT

TypeScript

Oct 6, 2026

Index only — not installable

by xiajiajun516

DeepSeek Harness (DSH) backup & restore plugin — export, import, migrate and sync your complete DSH configuration, plugins, MCP servers, skills and workspace. One-click migration to another machine.

Development & InfrastructureManifest valid

★ 215

MIT

TypeScript

Oct 18, 2026

dsh plugin --profile web add dsh-config-manager

by yjh051108

推荐组件(非必须):DeepSeek Harness 运行时注入器;已随 dsh-routing-suite 单仓库化保留,本仓库继续维护/发布。

Development & InfrastructureManifest valid

★ 166

TypeScript

Oct 16, 2026

dsh plugin --profile web add @dsh-external/dsh-super-injector

by jigjoy-ai

A CLI that turns a goal into a pull request - and a sandbox for testing concurrent AI coding agents on the Mozaik runtime.

Development & Infrastructure

★ 124

MIT

TypeScript

Oct 13, 2026

Index only — not installable