How dsh Plugins Actually Work

The background you need to read a capability card

中文版 · Written for people new to DeepSeek Harness


The one-sentence version

dsh is built on the principle that everything is a plugin. It sits on the Cordis framework: plugins register services and listen for events on a shared context object, ctx — and dsh's own core features (sessions, tools, LLM adapters) are written as plugins through that same mechanism.

Which means: there is no privilege gap between a plugin and the core. A third-party plugin you install uses exactly the interfaces the official modules use.


What a plugin looks like

The smallest plugin is a module exporting apply:

export const name = 'my-plugin'
export const inject = ['tools', 'sessions']   // services I depend on

export function apply(ctx: Context) {
  ctx.tools.register(/* ... */)
  ctx.on('session/event', (e) => { /* ... */ })
}

Three things worth noting:

ElementWhat it doesCommonly mistaken for
injectDeclares service dependencies, drives load order❌ a permission request
ctx.xxxRetrieves a service by key, no import needed
ctx.on(...)Attaches a hook — to observe, or to rewrite

inject is the most important point here: it resolves what loads first, **not what you may do**. Once loaded, a plugin holds every service it declared, with no runtime boundary enforcing anything.


What lives on ctx

Services occupy stable namespace keys, and plugins discover each other by key. Some common ones:

ServiceCapability
ctx.toolsRegister tools, intervene in the execution pipeline
ctx.llmModel adapters, streaming
ctx.sessionsThe session log
ctx.systemPromptSystem prompt assembly
ctx.apiProxyThe API request proxy layer
ctx.subprocessSpawning subprocesses
ctx.approvalAsking the user to confirm
ctx.sandboxSandbox backends (e.g. dsh-bash-sandbox)

The last four are why we mark certain plugins as carrying "powerful capability": they correspond to rewriting what the model sees, sitting between you and the model, and running system commands.


What happens during one exchange

turn/start → agent/pre-step → step/start → llm/stream → tool/call* → step/end → turn/end

A step is one model request plus the tools it calls; a turn contains zero or more steps.

Events dispatch in four modes, and the difference matters:

ModeBehaviourWhat a plugin can do
emitFire-and-forgetObserve only
parallelConcurrent, awaitedObserve + async side effects
serialSequential, returns valuesInfluence the result
waterfallMiddleware chain, wraps valuesRewrite the data itself

waterfall is why a plugin can rewrite rather than merely watch. A plugin hooked onto system-prompt/assemble can rewrite the prompt before it reaches the model — and nothing in the conversation you see on screen changes.


The session log: everything the model sees

dsh holds one hard invariant: what the model sees is logged.

The session log is append-only, and deriveMessages() projects the model's history from it. To feed the model anything extra, you must append a record — there is no path around it.

It is a clean design: it keeps "what did the model actually see" permanently auditable. And it is exactly why hooks like system-prompt/assemble deserve attention — they are among the few places that shape model input outside that projection.


Permissions: where things actually stand

dsh does have a runtime approval mechanism at the tool level:

ctx.on('tools/pre-execute', () => ({ kind: 'ask', reason: '...' }))
// or { kind: 'deny', reason: '...' }

A plugin can hold up a tool call for user confirmation, or refuse it outright. Together with ctx.approval and ctx.sandbox, the guardrails around tool execution are real.

Plugin installation has no equivalent.

When you install a plugin:

So "the plugin exceeded its permissions" does not apply — there are none to exceed. Tool calls are carefully gated, while the step that grants a plugin all of this happens silently.


What a capability card does about it

dsh-xray statically scans each plugin's shipped code and lists the above: which services it takes, which hooks it attaches, whether it patches the runtime, which environment variables it reads, whether it runs anything at install time — each with a file and line number.

That is not a substitute for a permission model. It just puts the information on the table until there is one.

Written from dsh's own documentation (architecture, cordis-primer, capability-seams, extension-cookbook) and from scan results. Spotted an error? Open an issue.