Back to home@xiuyuan18

dsh-auto-approve

Unofficial community plugin: automatic review of sandbox escalation requests for DeepSeek Harness (Codex Guardian-style) plus an /approve slash command

Stars
0
Language
JavaScript
Created
Aug 25, 2026
Updated
Aug 25, 2026
GitHub repo

Introduction

dsh-auto-approve

⚠️ Unofficial project. This is an independent, community-made plugin. It is not an official DeepSeek Harness product, is not affiliated with or endorsed by DeepSeek, and is not maintained by the DeepSeek Harness team. Use at your own risk.

Automatic review of sandbox escalation requests for DeepSeek Harness, plus an /approve slash command to manually approve a request that automatic review denied — the DSH counterpart of Codex's Guardian and its /approve command.

This package is a bundle: its dsh.bundle.patch (cordis.patch.yml) inserts the plugin row; the plugin then registers its own approval answerer, the /approve command, and its system-prompt context section at load. Installing it into any profile is one command:

dsh plugin --profile web add /home/xiuyuaned/dsh-plugins/dsh-auto-approve

dsh plugin runs pnpm add and reconciles dsh.profile.bundles — a dsh.bundle-declaring package joins the layer stack automatically. Removing is symmetric: dsh plugin --profile web remove dsh-auto-approve.

Setup

  1. Install dependencies once (self-contained — the project carries its own node_modules):

    cd /home/xiuyuaned/dsh-plugins/dsh-auto-approve && pnpm install
    
  2. Add the bundle to the profile (repeat per profile):

    dsh plugin --profile <name> add /home/xiuyuaned/dsh-plugins/dsh-auto-approve
    
  3. Restart the profile's app.

What it does

When the agent asks to escalate sandbox permissions (sandbox_permissions + justification on a sandbox-enforcing tool such as bash or the filesystem tools), the approval seam (approval/request) normally prompts the user. This plugin prepends an answerer that instead runs a dedicated review LLM call first:

  • allow → the escalation proceeds (allowed-once), no user prompt.
  • deny → the denial is recorded (per-session store with the reviewer's rationale) and the tool call fails closed, exactly like a user denial. The session keeps running: the model sees an ordinary failed tool call and may retry differently or wait for /approve.
  • ask from the reviewer (only allow|deny are offered) → equivalent to deny: recorded with the reviewer's risk and rationale, rejected without prompting.
  • undecidable (timeout / stream failure / genuinely malformed output) → Codex fail-closed: the denial is recorded and rejected without prompting, recoverable via /approve. The user is never interrupted.
  • circuit breaker (Codex parity): when review denies 3 consecutive escalations, or 10 of the last 50 reviews in one turn, the breaker trips: the turn's remaining escalations fail closed without further LLM reviews and the model is warned to stop retrying denied actions.

The reviewer sees the full tool call — the approval request's callId resolves to the session's tool/call event, whose raw arguments (e.g. the exact bash command) are rendered into the review prompt alongside the agent's justification and the transcript tail. When the call payload is unavailable, the review judges the request as stated.

The review prompt is adapted from Codex's Guardian (policy.md + policy_template.md): evidence handling (only real user messages are trusted authorization; plugin-injected user-role messages are not), user-authorization scoring, the risk taxonomy (data exfiltration, credential probing, persistent security weakening, destructive actions), and the outcome thresholds — with the same strict-JSON contract:

{ "risk_level": "low"|"medium"|"high"|"critical",
  "user_authorization": "unknown"|"low"|"medium"|"high",
  "outcome": "allow"|"deny",
  "rationale": "one sentence" }

One honest limitation: the reviewer recovers the full tool call from the session's tool/call event via the request's callId, so it usually judges the exact command. When that payload is unavailable (the call event is missing), it judges the mode + the agent's one-sentence justification plus the recent transcript tail (rendered compactly from the session log, including the session's own escalation-approval audit trail).

The /approve command (Codex's approve)

  • /approve — pops the web UI's question menu (Codex's denial picker): one option per recent denial, the reviewer's rationale as the description. Picking one approves it. When the UI seam is unavailable (headless profile, non-root agent, cancelled popup) it falls back to a plain text list with the same content.
  • /approve <id> — approves one retry of that exact action directly, without the menu.
  • Either way the answerer grants the next escalation whose fingerprint matches (tool + requested mode + normalized justification) and the model is woken with a followup message stating the user approved that exact action (Codex's GuardianApprovedAction fragment: "Treat this as approval to perform that exact action… Do not assume this also authorizes similar operations with different payloads."), so it re-issues the identical tool call.

The grant is single-use: after one matching escalation it is consumed; the model must not assume similar actions are authorized.

Config

FieldDefaultDescription
enabledtrueSet false to restore plain interactive approval for every escalation
provider / model'' (session's own model)Review LLM route override; otherwise the session's latest request/header model, then the deployment's agentDefaultModel
reviewTimeoutMs60000Per-review LLM timeout
reviewReasoningEffort'off'Reasoning effort for the review call. Default off disables thinking. A reasoning-by-default model would otherwise burn its whole output budget on reasoning_content and emit no answer, so every review fails closed (deny) as "undecidable" — even for benign escalations. Set low/high/max (with a larger reviewMaxTokens) to let the reviewer reason; empty uses the provider default. Models with no reasoning-effort control are retried without it.
reviewMaxTokens800Max tokens for one review answer. Raise when reviewReasoningEffort is set.
transcriptTailEvents40Recent session events rendered into the review prompt
transcriptEntryMaxChars400Per-entry truncation in the review transcript
maxDenialsPerSession10Denied actions kept for /approve
maxConsecutiveDenials3Circuit breaker: consecutive denials that trip the breaker in one turn (Codex parity)
maxRecentDenials10Circuit breaker: denials within the window that trip the breaker
denialWindowSize50Circuit breaker: recent-review window size (Codex parity)
storeDir$DSH_HOME/auto-approveJSONL denial store directory

Example pinning the review model:

- insert:
    - id: auto-approve
      name: 'dsh-auto-approve'
      config:
        provider: deepseek
        model: deepseek-chat

Runtime settings (web UI)

The plugin registers an auto-approve user-settings namespace (when a settings service is mounted, i.e. in the GUI), layered schema defaults → bundle entry config → user section, so the entry config above is the base layer and the GUI overrides only the fields you change:

  • Settings → Auto-approve (settings.section) — auto mode on/off, review reasoning effort (off/low/high/max/provider default), review max tokens, and the optional provider/model pin. Each row shows a Reset affordance while the field is user-overridden (reverts to the composed default).
  • Composer-bar toggle (conversation.input.left) — a compact sparkle pill rendered directly beside the permission select (the "workspace-write" dropdown in the input bar), so the auto-review state sits exactly where escalations happen. Sparkle tinted success-green = auto review on, dimmed = off.

The toggle shares one settings scope with the Settings page, so flipping either is reflected everywhere immediately. The sparkle (the harness IconSparkle16 glyph) is the plugin's "automatic" mark — deliberately NOT a shield: the permission select already uses shields for every sandbox mode (read-only / workspace-write / full-access), so a shield would read as another permission mode.

The Settings nav icon shows the same sparkle instead of the shell's generic gear. The shell's navIcon(id) map is closed (only models / agent-presets / plugins get bespoke glyphs), so the client marks its own localized nav row (client/settings-nav-icon.ts, the same MutationObserver technique dsh-better-sidebar ships) and settings.css replaces the gear with a currentColor mask of the sparkle — no shell modification, HMR-safe, and it follows the native nav hover/active colors.

Both surfaces read/write the namespace through the client settingsScope service (bound once at apply); the Host stays the fact source. Every escalation decision reads the live resolved value, so a change applies to the very next review — no restart. The user section is stored in the harness settings document (e.g. $DSH_HOME/settings.json); headless profiles without the settings service keep the entry config untouched.

All client surfaces (Settings page, composer-bar toggle, header chip) are localized (client/locales.ts, zh + en): each slot entry declares the auto-approve locale namespace so the renderer supplies a t seat that reads the active app language, and the renderer hands out a fresh t per locale revision — switching Settings → Language re-renders them immediately, and the Settings nav label follows too.

Lifecycle and interaction with DSH policies

  • The answerer registers with { prepend: true }, so it runs ahead of the host's interactive answerer regardless of bundle order; non-escalation approval asks fall through untouched. Auto-approvals are silent for the user (the waterfall short-circuits before the UI frame is published); denials surface as failed tool calls and are recoverable via /approve.
  • A session whose approval policy is 'never' never reaches any answerer — every escalation resolves 'rejected' deterministically, as documented by the approval seam. Auto-review is an approval channel, not a policy override.
  • Denials persist per session as JSONL under $DSH_HOME/auto-approve/ so /approve survives restarts. Durable session events are deliberately not used: out-of-repo plugin event types are outside KNOWN_SESSION_EVENT_TYPES, and the persistence layer refuses to reload a log containing an unknown non-ignorable event. Approved-retry grants are in-memory and single-use.

"Under review" indicator & runtime settings (web client half)

During an auto-review, a chip in the session header (left of the session log) shows Under review: bash → workspace-write — the same gradient-sweep shimmer as the harness's "Deep diving…" status line (dsh-turn-status-shimmer keyframes, mirrored in client/under-review.css) — followed by a brief ✓ Approved / ✗ Review denied flash when the review settles.

  • Data channel: the host registers a session projection auto-approve/review (src/projection.js) — a pure fold over the approval/asked + approval/decided audit events — and the host gateway streams it to the browser as session/projection frames; the client reads it with the framework's useProjection seat. No new wire protocol, no new session event types.
  • Rendering: the client half (client/, dsh.client manifest, prebuilt to lib/client.js via node client/build.mjs) registers three surfaces, all non-destructive slot appends:
    • the session-header review chip into the utilities list slot (conversation.session.header.utilities, order -1) — never replaces existing header rows and never touches the tool renderers. (Co-locating a badge with the tool call was tried first via a per-tool tool.call.toolview shadow and abandoned: it required re-bundling ui-tool/ui-primitives, whose markdown/ansi deps are absent from the npx-served harness closure — the tool cards rendered black/empty.)
    • the Settings → Auto-approve page into settings.section (id auto-approve, order 100) — auto mode, reasoning effort, max tokens, provider/model;
    • the composer-bar auto-mode toggle into conversation.input.left (id dsh-auto-approve-toggle) — a sparkle pill beside the permission select. The two surfaces share one settingsScope.bind({ namespace: 'auto-approve' }) and write the host-registered namespace, so a toggle in either place is reflected everywhere immediately. All surfaces are localized (zh/en via client/locales.ts + the framework t seat) and follow the app language live.
  • Activation: the client half is served at runtime by the host (/plugins/dsh-auto-approve/client.js, rev = content hash) — no web-shell rebuild needed. After dsh plugin --profile web add, restart the GUI and hard-refresh the browser.
  • Build: client/build.mjs (esbuild) bundles just the entry + locales + CSS (under-review.css, settings.css; react stays external), collects the CSS on globalThis.__DSH_AA_CSS__, and wraps the CJS output in window.__ModuleLoader__.load. No harness sources or heavy stubs are involved — the bundle is a few KB.

Project layout

  • index.js — the plugin: prepended approval/request answerer, /approve command, review-state projection, system-prompt context section, and the auto-approve user-settings namespace (live config source)
  • src/policy.js — review system prompt (Codex Guardian policy, DSH-adapted) + output contract + action JSON
  • src/transcript.js — compact session-transcript renderer for the review prompt
  • src/review.jsctx.llm review call, model resolution, strict-JSON parsing
  • src/store.js — per-session denial store (bounded memory + JSONL persistence)
  • src/breaker.js — per-turn rejection circuit breaker (Codex parity)
  • src/projection.js — review-state session projection (the client indicator's data channel)
  • client/ — web client half: session-header review chip, "Auto-approve" Settings page (+ sparkle nav icon via the settings-nav marker), composer-bar auto-mode toggle (sparkle glyph), zh/en dictionaries (locales.ts), settings-nav marker (settings-nav-icon.ts), build script (build.mjs), indicator + settings CSS
  • lib/client.js — prebuilt client bundle (window.__ModuleLoader__ format)
  • cordis.patch.yml — the bundle composition layer (plugin row)
  • AGENTS.md — guidance for AI agents working on this project
  • package.json / pnpm-lock.yaml — ESM manifest and lockfile

exports must expose ".", "./client" and "./package.json": the host's client-modules registry resolves every loaded entry with require.resolve('<pkg>/package.json') to read the dsh.client declaration; a missing "./package.json" entry throws ERR_PACKAGE_PATH_NOT_EXPORTED, the registry caches the package as "not a client row", and the UI half never reaches the boot graph until the GUI restarts.

Development

node --check index.js src/*.js
node test/smoke.js            # offline logic smoke tests (no harness needed)
node client/build.mjs         # rebuild lib/client.js
node test/client-smoke.mjs    # load + verify the built client bundle
node test/integration.mjs     # optional: real-cordis activation + live settings wiring

Known limitations

  • The review sees the full tool call only when the request's callId resolves to a session tool/call event; otherwise it judges the mode + justification + transcript tail.
  • Reasoning models can starve the review: a reasoning-by-default model at reasoningEffort: high spends its whole maxTokens budget on reasoning_content and returns an empty answer, which fails closed to deny — reading as "too strict." Fixed by the reviewReasoningEffort: 'off' default; if you re-enable reasoning, also raise reviewMaxTokens.
  • /approve grants one retry; if the agent rewords the justification, the fingerprint no longer matches (the followup message carries the exact approved action text to prevent this).
  • A grant is lost if the app restarts between /approve and the retry (in-memory by design).