SteelUnion/dsh-confirm-gate

dsh-confirm-gate

按轮统计持久化操作,按总数逐级升级:先提醒,再给明确的停止指令,最后请求确认。每次调用都现读会话的沙箱模式,在 read-only 与 workspace-write 下保持沉默。

未分類agent-safetyai-agentai-governance

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 mode · Plugin counts? · Why read-only · ❌ No · The sandbox already holds every boundary workspace-write · ❌ No · Same danger-full-access · ✅ Yes · Nothing is holding the boundary custom · ✅ Yes · A custom mix still leaves the boundary unheld undefined / no shell service · ✅ Yes · This executor does not confine Unreadable (both sources threw) · ✅ Yes · Fail 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:
| Mode | Write a file | pwsh | Gate 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

Situation · Fits? 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

Alternative · Why not Hard-block instead of counting · Tried 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 hooks · The 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 sandbox · Different 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
   └─────────┴─────────┴──────────┘

Tier · Behaviour Message contains `confirmWord` · The whole turn is released: no counting, no reminders, no blocking 1 ~ freeLimit · Allowed, reminder injected freeLimit+1 ~ hardLimit-1 · Allowed, STOP instruction injected >= hardLimit · Returns { 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 message · Result 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

Parameter · Default · Meaning · Effect of an extreme value freeLimit · 10 · Operations allowed without confirmation · 0 → the first operation already enters the instruction tier hardLimit · 15 · Hard limit; confirmation requested on reaching it · 1 → confirmation requested on the first operation

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

The trigger word

Parameter · Default · Description confirmWord · confirm · The 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

Parameter · Default reminderText · Warning: persistent operation without confirm. Ask the user for confirm. warningText · Instruction: 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. askReason · Persistent operation limit reached; user confirmation is required to continue.

Counting scope

Parameter · Default · Description include · [] · Only count tool names matching these (* wildcards supported). Empty = count all exclude · [] · Exclude matching tool names resetOnUserMessage · true · Whether a new user message resets the counter resetOnConfirm · false · Legacy: 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.