Architecture

September 5, 2026 · View on GitHub

How dsh-auto-approval-plugin is put together, and how a request flows through it. Terminology follows the DSH approval seam: request = one approval/request; answerer = a waterfall listener that produces an ApprovalOutcome; grant = the allowed-once outcome.

Components

FileRole
lib/index.jsCordis plugin entry (apply): row registration, the approval/request answerer, model-facing prompt narration, and the configuration HTTP surface.
lib/decide.jsPure, dependency-free decision core (classifyRequest). No Cordis imports; unit-tested in isolation.
lib/settings.jsThe auto-approval settings namespace: schema, mode vocabulary, legacy migration helpers, validation.
lib/http.jsSame-origin-gated REST handlers for the web settings card.
client.jsWeb client plugin: the Auto Approval page in the settings sidebar (settings.section slot), i18n dictionaries, status display.
cordis.patch.ymlBundle patch: restates the permission preset table, inserts the auto-approval row with its defaults.

Request flow

tool call (pwsh/bash/write/edit)
  └─ sandbox denies, model retries with sandbox_permissions + justification
       └─ dsh-tools → approval.request({agent, toolName, callId, reason})
            ├─ session.append("approval/asked", {id, toolName, callId})
            └─ ApprovalService.decide → ctx.waterfall("approval/request", ...)
                 ├─ this plugin's answerer (prepend):
                 │    1. effective config (mode off/gated/global gate) → next() if inactive
                 │    2. findToolCall(session.snapshotEvents(), callId, toolName) → real arguments
                 │    3. classifyRequest → "allow" → return "allowed-once" (chain stops)
                 │                    → "defer"/error → next()
                 │    4. remember() + ctx.logger.info (audit aid)
                 └─ next answerer (e.g. dsh-host-apiproxy → browser prompt)
                       → "allowed-once" | "rejected" | "cancelled" | "unavailable"
            └─ session.append("approval/decided", {id, outcome})   (audit pair)

The answerer returns "allowed-once" only for requests it can verify from the recorded tool/call arguments. Every other path calls next(); the plugin never produces rejected/cancelled, so it cannot lock a session out.

Decision core (classifyRequest)

Order of checks, first match wins:

  1. tool not pwsh/bash/write/editdefer
  2. missing/oversized inputs → defer
  3. dangerousPatterns match → defer (scope control, checked first for commands and targets)
  4. command tools:
    • no shell metacharacters (; & | < > \ $( newline):
      • harmlessPatterns match + not git/hub → allow anywhere
      • harmlessPatterns match + git/hub → allow only when workdir ∈ trusted area
      • workdir ∈ trusted area + command references a trusted path → allow
    • everything else → defer
  5. fs tools: resolved target ∈ trusted area → allow; else defer

Path containment runs on real identity: the deepest existing ancestor is realpath-resolved (same canonicalization the DSH filesystem sandbox uses), so symlinks/junctions cannot smuggle a target outside a trusted area.

Configuration lifecycle

cordis.patch.yml (composition base)      ← bundle patch, defaults

auto-approval settings namespace          ← ~/.dsh/settings.yaml user layer,
   (schema defaults → base → user layer)    hot-reloaded, web card writes here

effective() = normalizeConfig(resolved)   ← legacy booleans folded into mode

read at every decision boundary           ← card edits apply immediately

Legacy documents from ≤ 2.0.0 (enabled / requireTrustedPreset) are folded into mode on every read (normalizeConfig), and a one-shot migration (replace) strips the stored legacy keys once, both at plugin start and after any write from an older client.

Security contract

  • Never denies: unmatched, unsupported, unparseable, or erroring requests all delegate via next().
  • One-shot: allowed-once grants exactly the one call that asked; the session's standing sandbox mode is untouched.
  • Real arguments, not justifications: the decision reads the recorded tool/call event by callId from the session log.
  • Gated surface: the config API accepts only loopback (or configured trustedHosts) same-origin requests; trustedHosts itself is composition-only and cannot be altered through the API.
  • Validation before persist: settings writes resolve through the schema and assertValidEffectiveConfig (absolute trusted areas, compilable regexes) before anything is written to disk.