dsh-subagent-gate
Ask a human before a delegated subagent widens its sandbox.
DeepSeek Harness pins every delegated child session to approval: "never" (see
captureDelegatedPolicyOverrides in @deepseek-ai/dsh-subagent), so an
escalation request coming from a subagent is rejected before anybody is asked —
a parent can be asked, its child cannot.
This plugin re-arms exactly that one knob to ask for children whose subtree
still has a human in the loop, and answers the resulting approval request by
asking the person. Nothing else changes: the sandbox mode, the permission preset
table and the parent's own behaviour stay stock.
Host-only plugin: no client bundle, no UI strings, no remote methods.
How it works
agent/created ─────► Coverage ─────► Knobs (child: never → ask)
approval/request ──► Coverage ─────► Pipeline ─────► Outcome ─────► Audit
│
└─ stages: human
rules / denylist / trust /
judge / autoapprove (planned)
- Managed children only. A child is managed when some ancestor runs under
ask. Children of adanger-full-access/neversession are left alone: they already inherit full access and have nothing to escalate. - Sandbox untouched. Only the child's approval policy changes. The gate decides whether a one-off widening happens; it never decides how far the child can reach on its own.
- Only approvals are gated. The plugin hooks
approval/requestand nothing else: ordinary tool calls — including calls that the sandbox denies outright — never reach it. - Fail closed. Rejection, cancellation, timeout, an unavailable answerer or
any internal error resolve to
rejected.allowis produced only byallowed-oncefrom the person. - One card at a time. Concurrent asks are serialized FIFO; a queued ask has not started its timeout yet.
allowed-onceonly. One confirmation grants one call and nothing is remembered. Leaf grants ("allow this child for its session") are a planned stage, not a core change.
Install
# npm
dsh plugin --profile <profile> add dsh-subagent-gate
# or from a packed tarball
npm pack
dsh plugin --profile <profile> add ./dsh-subagent-gate-0.1.3.tgz
Install a packed tarball, not a link: on the source directory: a linked
package resolves its @deepseek-ai/* peers from outside the profile and fails to
load.
Configuration
$DSH_HOME/dsh-subagent-gate/config.json — created on the first on/off;
defaults apply while the file is absent:
{
"version": 1,
"enabled": true,
"forwardTo": "root",
"directProbeMs": 5000,
"humanTimeoutMs": 0,
"onTimeout": "reject",
"onNoAnswerer": "reject",
"provenance": true,
"promptSuffix": "",
"audit": true,
"stages": [{ "id": "human", "enabled": true, "order": 100 }]
}
| key | meaning |
|---|---|
forwardTo |
target strategy id for the confirmation card (see below) |
directProbeMs |
budget for the one-time auto probe; 0 disables probing |
humanTimeoutMs |
0 waits for the human indefinitely; otherwise a timeout resolves per onTimeout |
onTimeout / onNoAnswerer |
reject (default) or allow |
provenance |
prefix the ask with [subagent <id> · depth <n>] and pass a display line |
promptSuffix |
extra text appended to the model-facing note |
audit |
append one JSONL record per decision to audit.jsonl |
Target strategies
| id | path |
|---|---|
root (default) |
the nearest non-subagent ancestor — the session the user is looking at, so the card is always visible |
self |
the child's own session (visible only while that session is open) |
auto |
probe self once per process for directProbeMs, then fall back to root and stay there |
| any other | looked up in the target registry; an unknown id falls back to root |
root is the default because the stock Web UI binds the approval card to the
session that is currently open: a subagent listed in a sidebar tree is still a
different session view.
Commands
/subagent-gate status # mode, target, timeouts, armed children, config path
/subagent-gate on|off # enable/disable; "off" restores armed children
Architecture
A stage is the only extension point the core knows about:
{
id: "human",
order: 100,
failClosed: true, // a throw denies (default); false abstains
appliesTo(ctx) { … }, // cheap scope filter
decide(ctx) { return "allow" | "deny" | "ask" | "abstain"
| { decision, final: false }; }
}
final: false enables an appeal chain: a judge can deny non-finally and the
human stage after it is still asked — the "ask the human only after the judge
said no" mode. With no final verdict the collected signals resolve strictly
(deny > ask > allow).
| file | role |
|---|---|
src/index.js |
cordis wiring: listeners, command, fail-closed boundary |
src/coverage.js |
who is managed, root resolution, 8-hop cycle-safe walk |
src/knobs.js |
never → ask, restore, remember |
src/pipeline.js |
ordered stages, final/non-final verdicts, strictest resolution |
src/ask.js |
the human stage: routing, probe/fallback, timeout, provenance |
src/targets.js |
target-strategy registry — the confirmation-UX extension point |
src/queue.js |
one card at a time |
src/audit.js |
append-only JSONL |
src/config.js |
defaults, normalization, persistence |
src/prompt.js |
the model-facing note for armed children |
Extension points
- Stages —
pipeline.upsert({ id, order, decide })plus a config entry.rules,denylist,trust,judge,autoapproveare planned as stages. - Target strategies —
service.registerTarget(id, (ctx, gate) => ({ agent, path, probe? }))so another plugin can add a transport (modal, notification, webhook, a dedicated approvals session) without touching this code. promptSuffix— extra model-facing wording without a rebuild. Add rules there only after an observed failure, not speculatively.
Compatibility
Written against DSH 0.2.x and tested on 0.2.2-rc.1. It uses public seams
only: agent/created, approval/request, approval.setPolicy,
approval.overrideOf, systemPrompt.context, commands.register.
Limitations
- A card is bound to the session you are viewing — hence the
rootdefault. - Approval asks only. Calls that would bypass the sandbox without asking
(for example an MCP tool whose server does the I/O in its own process) are not
seen by this plugin. Closing that needs a per-call stage on
tools/pre-execute. - One model-facing sentence contradicts the host. DSH appends an
unconditional delegation note to every child ("your permission scope cannot be
widened … rejected automatically"). It is true only while the child is pinned
to
never— the very thing this plugin changes — so the gate states the real contract and stops there. The escalation procedure itself ("narrowest wider mode", "one-shot retry of the exact command", "one sentence of justification") is already in the model-facing tool schema, identical for parents and children. The clean fix belongs upstream: emit the delegation note only when the policy isnever, the way theneverpolicy sentence already is.
Development
node test/run.mjs # 57 assertions, no host required
npm pack # produces the installable tarball
Roadmap
rules— declarative allow/deny on tool + args.denylist— dangerous-command patterns with normalized matching.trust— an identical call already approved in this session.judge— LLM auto-approval; withfinal: falseon denial thehumanstage becomes "ask only after the judge rejected".autoapprove— pattern whitelist, so routine widenings never reach a person.- Leaf grants — "allow this child for the rest of its session".
- Optional scope filter — restrict the gate to selected workspaces/roots.
License
MIT — see LICENSE.
No comments yet. Be the first to write one.