dsh-sandbox-escalation-guard
A DeepSeek Harness plugin that stops the sandbox-escalation fields being offered
to a model when the calling session cannot grant them.
It fixes a GPT-specific failure. GPT-family models complete every optional
property in a tool's schema, and the two optional properties on bash, write
and edit are exactly the two a full-access session can never use. So GPT fills
them in on every call, every call is rejected before it runs, and the error text
never says that the fix is to omit them. The turn dies with nothing executed.
A model that sends only what a call needs never sees this — which is why it
looks like "GPT is broken" while other models are fine in the same session. The
fix is not a better prompt: it is to stop publishing fields that cannot be
filled. See why this presents as GPT-specific.
The bug
The DSH escalation fields — sandbox_permissions and justification — are
advertised from a load-time capability fact but judged at execution against
a per-session policy fact:
| Source | Question answered |
|---|
| Advertisement | ctx.shell.sandboxMode | Is a confining executor mounted? |
| Execution | ctx.sandboxPolicy.resolve().mode | What mode is this session actually in? |
They disagree in exactly the case that hurts: a session sitting at the ceiling.
A session whose effective mode is danger-full-access is handed:
"sandbox_permissions": {
"type": "string",
"enum": ["workspace-write", "danger-full-access"]
}
Both values are unusable. workspace-write is a reduction, and
danger-full-access is the mode already in force — and the check is
strictly wider, so neither can ever be granted. The system prompt
simultaneously instructs the model to use that field to "escalate immediately".
A model that fills in every optional property it is shown — the GPT family does
this — then cannot succeed and cannot tell why:
Error: invalid justification: expected a non-empty sentence
Error: sandbox escalation to "danger-full-access" is not strictly wider than this call's current "danger-full-access" mode
Error: sandbox escalation to "workspace-write" is not strictly wider than this call's current "danger-full-access" mode
Neither text names the only actual repair, which is to omit both fields. The
observed result is the same command retried until the turn dies.
Why this presents as "GPT is broken"
Nothing here is model-specific in the harness. The difference is a habit, and it
collides with this defect perfectly:
GPT-family models populate every optional property in a tool's schema. Given
command (required) plus sandbox_permissions and justification (optional),
they do not send the minimum the call needs — they send a fully-formed instance
of the schema they were shown. Asked for one string, they fill in three.
That habit is usually harmless. Here it is fatal, because the two optional
properties are the exact two that a full-access session can never use. So the
model helpfully completes them on every call, each call is rejected before
anything runs, and the error text points at the justification field rather than
at the real instruction, which is to leave the fields out. Retrying with the
same habit produces the same rejection. The turn ends with no command executed
and no usable signal about why.
A model that only sends what the call needs never touches those fields and never
sees the bug. From the outside, that looks like one model being broken — same
session, same harness, same repository, different models, only one of them
failing.
The fix follows directly from the diagnosis: if the model completes whatever
it is shown, stop showing it things it cannot use. Once both fields are gone
from the schema, there is nothing left for the habit to complete, and the
failure mode disappears for the model that was most prone to it — without the
model needing to know it existed, and without a prompt telling it to be careful,
which is the kind of instruction a schema-completing model is least likely to
reliably follow.
This is why the primary fix is suppression rather than a clearer error message.
A better message asks the model to behave differently; removing the field does
not depend on the model's cooperation at all. The tools/pre-execute
correction exists only for the residual case — a cached schema, or a model that
ignores the schema it was given.
Evidence
From one live session (effective mode danger-full-access, approval policy
never), comparing a model that completes optional properties against one that
does not:
| model | tool calls | emitted sandbox_permissions | bash calls | bash + optional field |
|---|
zai/glm-5.3 | 211 | 0 | 86 | 48 |
oauth-codex/gpt-6-astra | 10 | 8 | 8 | 8 |
The GLM column is not "a model that tried and succeeded" — it emitted the field
zero times in 211 calls. It never entered the failure mode, because it sends
only the properties a call needs. The GPT column emitted it on every bash
call and lost all eight to the same rejection.
Same session, same schema, same repository, same effective mode. The only
variable is whether the model completes the optional properties it was shown —
which is why this reads from the outside as "GPT is broken" rather than as a
schema defect that one model's habits expose.
Confirmed at the wire level too: the request/header event for that session
shows bash, write and edit each advertising sandbox_permissions with
enum: ["workspace-write","danger-full-access"], so the field was genuinely
published to the model rather than being something it invented.
What this plugin does
1. It stops publishing the impossible (the actual fix)
The tool set is still mutable at system-prompt/assemble, which is a
sanctioned extension point and runs per request. The rewrite is decided by a
session fact — whether an escalation could be approved at all — never by the
current access level:
- when the session's approval policy is
never, and for any unrecognised mode,
remove both fields entirely and strip the escalation paragraph from the
tool description that would otherwise keep urging the model to use them;
- when an escalation genuinely could be approved, leave the surface exactly as
the harness built it.
An earlier revision instead kept only the enum values strictly wider than the
current mode. That is gone, for two reasons:
-
It moved the cached prefix. The tools block is the front of the cache, so
rewriting it on an access-level change discarded the entire cached
conversation behind it.
-
The access level does not move on its own. The shipped permission presets
bundle the two knobs, and switching one writes both:
| preset | sandbox | approval |
|---|
read-only | read-only | ask |
workspace-write | workspace-write | ask |
danger-full-access | danger-full-access | never |
So the access level never changes while the policy stays put. The policy is
therefore the key that is constant for the whole life of a preset, which is
exactly the lifetime over which the cached prefix must survive.
Because the deciding fact is the approval policy rather than the mode, the
published surface is byte-identical on every request and does not move when the
access level is changed in place. That is the property that protects the cache,
and test/mount.test.mjs and test/real-services.test.mjs pin it directly.
There is no coverage lost in any shipped preset, which is worth stating because
an earlier version of this file implied otherwise:
| preset | what the model is offered | correct? |
|---|
read-only | ["workspace-write", "danger-full-access"] | yes — both are genuinely grantable |
workspace-write | ["workspace-write", "danger-full-access"] | danger-full-access is grantable; workspace-write is the downward direction, and escalation only ever goes wider |
danger-full-access | (fields removed) | yes — nothing is wider, so every value would be rejected. This is the reported failure. |
Escalation is strictly widening, so a model never asks to narrow itself. The
surface above is consequently the correct one for every preset the harness
ships.
It still judges grantability by the same strictly-wider ladder
@deepseek-ai/dsh-sandbox exports and approveEscalation enforces, so the two
cannot disagree about what is grantable. That ladder is mirrored in
lib/wider-modes.js rather than imported, which is why this package has no
runtime dependency — it plugs into the host contract alone (apply +
inject). A test/wider-modes.test.mjs drift guard compares the mirror against
the harness's own export and fails if DSH ever changes the ladder.
For a session under a never policy the model now receives a bash tool with only
command and description, and no escalation prose at all — in every mode.
2. It corrects a call that carries them anyway
A client holding a cached schema, or a model that ignores its schema, can still
send an unusable request. tools/pre-execute then denies it with text that
names the effective mode, states that no value is grantable, and names the
repair — instead of the two opaque validations.
This is a fallback where (1) applies, and the only mechanism where it does not.
In a session under an ask policy the surface is deliberately left intact, so
this listener is what keeps an unusable call from failing opaquely.
The plugin never widens anything
It only stops offering modes that cannot be granted. It never grants a mode the
harness would have refused, and it cannot: it removes capability from the
advertised surface and adds none.
Suppression matrix
The headline guard is uniform across modes — that is what keeps the cached
tools prefix byte-stable for the length of a session.
| session can have escalation approved? | advertised enum | description prose |
|---|
no — approval policy never, no approval service, or unrecognised mode | (fields removed) | escalation paragraph removed |
yes — an approval channel exists and policy is not never | (left exactly as the harness built it) | kept |
The two conditions are session facts, not properties of the current access
level. ApprovalService.decide returns "rejected" outright when the effective
policy is never, before any approver is consulted, so under that policy an
escalation is impossible in every mode — and the right published surface is
also the same in every mode.
An unrecognised mode grants nothing rather than everything: SandboxMode is a
validated closed union, so this cannot arise from a healthy host, and offering an
escalation would be the unsafe guess.
The residual, and the per-call correction
When escalation genuinely can be approved, the guard does not edit the surface
at all. A call that still carries an unusable request is then corrected at
tools/pre-execute, which re-evaluates per call and costs nothing to re-run.
This covers:
- a session under
ask whose current mode happens to leave nothing wider — for
example danger-full-access, from which no escalation is ever grantable;
- a client holding a cached schema;
- a model that ignores its schema entirely.
The correction names the effective mode, states that no value is grantable, and
names the repair — unlike the two opaque validations it replaces. See
What this plugin does and does not cover
for why the enum is not trimmed per mode.
Install
dsh plugin --profile web add github:iimaguest/dsh-sandbox-escalation-guard
That installs from GitHub, writes the dependency, and adds the profile's
bundles entry, so the cordis.patch.yml in this package mounts
automatically. Confirm the row:
grep -A 14 '"bundles"' ~/.dsh/profiles/web/package.json
Activation is a profile load, not a file write. The plugin takes effect when
the profile is next loaded.
Managing the install
# pull new commits that have landed on GitHub
dsh plugin --profile web update dsh-sandbox-escalation-guard
# remove completely
dsh plugin --profile web remove dsh-sandbox-escalation-guard
To disable it without uninstalling, remove dsh-sandbox-escalation-guard from
the profile's bundles list. To develop against a local checkout instead of
GitHub, dsh plugin --profile web add /path/to/dsh-sandbox-escalation-guard.
Configuration
- id: dsh-sandbox-escalation-guard
name: dsh-sandbox-escalation-guard
config:
warn: true # log each intervention to the host console
resolveMode: ... # testing seam only; omit in production
escalationPossible: ... # testing seam only; omit in production
| field | default | meaning |
|---|
warn | true | Log when the schema is narrowed or a call is corrected. |
resolveMode | (unset) | Overrides effective-mode resolution for the per-call correction. Testing seam; production reads the session's own sandbox/mode fold through sandboxPolicy. |
escalationPossible | (unset) | Overrides whether the published surface keeps the escalation fields. Testing seam; production reads the approval policy. |
What this plugin does and does not cover
This is stated plainly because the coverage changed during development, and the
honest version is narrower than an earlier claim in this file.
It covers the case where escalation can never be approved. When the
deployment's approval service exists and the session's policy is never,
ApprovalService.decide returns "rejected" before any approver is consulted,
so an escalation is impossible in every mode. The guard then removes the
escalation fields from the published surface, uniformly, in every mode. This is
the reported configuration (the failing session logged
approval/policy: {policy: never}), and it is fixed.
It also corrects, per call, any unusable request that arrives anyway — a
cached schema, a model ignoring its schema, or a session whose policy is ask.
That check runs at tools/pre-execute, costs nothing to re-evaluate, and names
the exact repair.
It does NOT narrow the advertised enum to the values grantable from the
current mode. An earlier revision did, and that was wrong twice over:
-
It broke the cache. The enum moved whenever the access level changed, and
the tools block is the front of the cached prefix, so a mode switch discarded
the entire cached conversation behind it.
-
It published a value that was going to be rejected anyway. A
workspace-write session advertised ["danger-full-access"]. Omitting a
field is always safe; publishing a value that is later rejected is what breaks
a model. The set that is safe in every mode a session can be switched into
is the intersection across those modes — and it is empty as soon as the
range includes danger-full-access:
| mode | grantable from it |
|---|
read-only | workspace-write, danger-full-access |
workspace-write | danger-full-access |
danger-full-access | (none) |
No value is grantable from all three, so a surface that never offers a
rejected value cannot offer any value.
The one residual, stated plainly. The remaining gap needs a composition no
shipped preset produces: an approval policy of ask paired with
danger-full-access. There the fields survive (the policy says an escalation
could be approved) while the mode says no value is wider, so a GPT-family model
can still fill them and collect one rejected call — corrected by the
tools/pre-execute listener rather than prevented. That listener names the
effective mode and the exact repair, so the failure is a clear correction rather
than the opaque loop that motivated this plugin.
If you build such a composition and want the tighter surface, set
escalationPossible: false to suppress uniformly. That is a statement about your
deployment, made by your deployment — not a guess this plugin makes from a mode
it cannot know is stable.
Screenshots
What the model is handed, before and after
Grantability by session mode
Where the guard sits
Verified against the harness
These are diagrams of the harness's own contracts rather than captures of a UI,
and every value in them is copied from verified output — generating them is
npm run screenshots, and the mode table in the last image is exactly what
npm run verify prints.
Reproducing the claim
npm install
npm run verify
Mounts the plugin against a real Cordis context and a real systemPrompt
registry and prints the model-facing bash schema for each session mode — the
plugin's observable claim, reproducible rather than described.
Tests
npm test
48 tests:
test/guard.test.mjs — the suppression matrix, the strictly-wider table
against approveEscalation's judgement, and prose stripping.
test/wider-modes.test.mjs — drift guard: the mirrored ladder is compared
against @deepseek-ai/dsh-sandbox's own export whenever the harness is
resolvable, and skipped (not failed) when it is not.
test/mount.test.mjs — the plugin applied to a real Cordis context and a
real systemPrompt registry, asserting what the model would be handed, that a
session which can escalate keeps the harness's own bytes untouched, that the
published surface does not move when the access level changes, that
unmounting restores the original surface exactly (so this is an ordinary plugin
row with no residue), and that the pre-execute correction fires only on
unusable requests. Some of these mount the plugin before the policy service
exists and then provide it, which is the real boot order — see the note below.
Others mount neighbouring listeners on both sides of the guard, and
re-assemble fifty times per mode to prove the output bytes never move.
test/real-services.test.mjs — the same contract mounted against the
harness's real ApprovalService and SandboxPolicy, not the testing seam.
This is the file that proves the gate reads the harness's own approval fact:
a fake { effectivePolicy: () => 'never' } agrees with any implementation,
including a wrong one. Skipped (not failed) when the harness is unresolvable.
The waterfall must be chained
ctx.waterfall dispatches through a chained thunk — (cbs.shift() ?? inner) —
so a listener that returns without calling next() ends the chain for every
listener registered after it. The first version of this plugin did exactly that,
which silently suppressed the system-prompt/assemble listeners of
dsh-session-reference and dsh-agent for the whole process. Their
contributions never reached a request and nothing reported the loss; it was
found by probing the dispatcher against two listeners, not by any single-listener
test. The listener now awaits next() and narrows the result, which is also
the correct order — the model must receive the final assembly.
The service-ordering trap
ctx.get('sandboxPolicy') must not be read once inside apply and closed over.
A bundle patch is appended to the composition, while sandbox-policy sits
earlier in the base tree and is provided asynchronously, so the row can load
before the service exists. Closing over the result binds undefined and the
plugin then narrows nothing for the entire session — silently, having printed a
warning that looks like a missing dependency rather than a race.
Declaring sandboxPolicy in inject is the other wrong answer: it defers
apply until the service appears, so on a composition that genuinely lacks it
the plugin waits out the session with no hooks registered and no diagnostic at
all. The guard resolves the service per request instead, mounts unconditionally,
and reports a genuinely absent service once, at the first request it cannot
serve. mount.test.mjs pins both halves: untouched before the service arrives,
narrowing immediately after.
The escalation-prose fixtures in guard.test.mjs are copied verbatim from the
shipped dsh-tool-bash and dsh-tool-fs descriptions. If a DSH upgrade reworks
that wording, the prose tests fail loudly rather than this package silently
ceasing to strip prose.
Known limits
It cannot repair the schema's origin. This plugin rewrites the schema on its
way to the model; it does not change dsh-tool-bash or dsh-tool-fs. The
underlying predicate — advertising from ctx.shell.sandboxMode while validating
against ctx.sandboxPolicy.resolve() — remains the harness's own, and the
correct upstream fix is to gate the fields on the session's resolved mode (and
to accept the current mode as a no-op). This plugin is the durable workaround
for an install that must not patch node_modules.
Prose stripping is anchored, not structural. The escalation paragraph is
removed by matching its leading clause. If a future DSH rewrites that text the
schema fix still applies and only the prose is left behind — the model keeps its
instructions but loses the fields they refer to. The pinned tests make that
visible.
The pre-execute fallback denies rather than executes. Letting the call
proceed by stripping the arguments would require re-implementing tool
dispatching, because exec.arguments is deep-frozen before any plugin
observes it. A denial that names the repair is the safe choice; with (1) active
it should never fire.
A listener registered after this one can still re-add the fields. The
guard narrows the assembly returned by next(), which carries the work of every
listener upstream of it in the chain. A plugin that appends tool schemas and
happens to register after this one would put un-narrowable fields back.
Middleware order is absolute and cannot be corrected from inside the waterfall.
This does not arise with the shipped set: dsh-session-reference and
dsh-agent both call next() and only read assembly.variables, adding no
tools.
On prompt caching
The guard rewrites request content, which is the exact shape of change that can
quietly destroy a provider's prefix cache. So this is measured against what the
providers actually document, not assumed.
What the providers say
| Provider | Rule |
|---|
| OpenAI | Changing tools changes "names, descriptions, schemas, ordering, or tool-specific instructions". Cache reuse "requires the entire rendered prefix to match". The cache-routing hash is taken over the initial tokens "including tool definitions when present". |
| Anthropic | The hierarchy is tools → system → messages; "Modifying tool definitions" invalidates the entire cache at every level. |
| DeepSeek | Caching is automatic and disk-based; a request hits only by fully matching a persisted cache prefix unit. |
All three agree on the operative point: tool definitions sit at the front of the
prefix, so they must be byte-stable for reuse to happen at all.
Why this plugin satisfies it
The bytes are constant for the length of a session. The effective mode is a
session fact, so narrowTools yields identical output on every assembly. The
schema is narrowed once, in effect, and every later turn sends the same bytes.
The cache is written on the first request and read thereafter — unaffected in
steady state.
Nothing varying is introduced. No timestamp, counter, identifier, token or
regeneration enters the output, so the routing hash is stable too.
Neither name nor order changes. Only properties entries and the
description text are filtered; the tool list keeps its order and every tool keeps
its name. Property insertion order is preserved from the input, so a rewrite
never reorders keys and changes bytes without changing meaning.
The result never depends on request history. Rendering a given mode produces
the same bytes whether it is the first request or the hundredth.
It makes the cached prefix smaller, not different. The escalation fields are
removed rather than rewritten, so the published definition is a strict subset of
the original.
What is pinned by tests
test/mount.test.mjs asserts the contract directly:
- 50 consecutive assemblies per mode produce exactly one distinct
JSON.stringify of the tool surface;
- the same mode renders identically no matter where it falls in a sequence of
differing modes;
- tool names and list order survive narrowing;
- property key order survives narrowing.
Mid-session access changes: the honest cost
An earlier version of this section claimed the harness already invalidates the
prefix on a mode change, via the sandbox:policy prompt context. That was
wrong, and the correction matters because it changes who pays.
The policy text is an appended message, not part of the prefix.
dsh-sandbox-policy registers a sandbox:policy context whose text is a
function of the resolved mode, but dsh-agent-loop renders the whole context
snapshot and appends it to the message list:
const context = this.runtimeContext.project(joinContextSections(sections), sections)
// ...
{ kind: "enter", messages: context === void 0 ? claimed : [...claimed, context] }
The snapshot even announces itself: "This snapshot supersedes earlier
runtime-context snapshots." Content appended at the end of a conversation
cannot invalidate anything cached ahead of it, so a mode change costs nothing
by itself.
tools is the channel that carries the cost. assembly.tools feeds both
buildRequest and toolsChanged(), which drives the request-series restart;
headerEquals compares schemas by canonical JSON. The guard's rewrite is a
tools rewrite, so it is the thing that moves the cached prefix.
And the tools block genuinely would not move without this plugin. The
advertised escalation enum derives from a mount-time capture — dsh-tool-bash
reads const defaultMode = ctx.shell.sandboxMode once in apply, and
ESCALATION_TARGETS follows from it — while resolveSandboxPolicy resolves per
call. That load-time/execution-time split is the root bug this plugin exists to
fix, and a side effect of it is that the published schema is static across a
mode change. So nothing in the harness would invalidate the prefix, and the
guard's rewrite is the sole cause.
So the guard does introduce a cache write on a mode change — one that would
not otherwise occur. Being precise about the size of that:
| cost of one mid-session mode change |
|---|
| Harness alone | nothing — the snapshot is appended, the tools block is static |
| With this guard | one cache write, then reads resume normally |
It is still the right trade, for a stated reason. Without the rewrite the
model keeps a schema offering escalation fields that no longer work — which is
exactly the failure this plugin exists to prevent, reinstated mid-session. A
single cache write per mode change buys a surface that always matches the
session's real permissions. Mode changes are user actions, not per-turn events,
so the frequency is low by construction.
It cannot be avoided from inside this design. There is no way to correct a
published schema without changing it, and the fields live in the tools block.
The alternative is recovery rather than prevention — correcting the call after
it is rejected instead of never advertising it — which costs no cache write but
costs one failed tool call per affected turn. The related
apex-mochen/dsh-sandbox-arg-guard takes that route. The two are complementary,
and this is the specific axis on which they differ.
Steady state is unaffected, which is the property that matters day to day:
with the mode unchanged, the tool bytes are identical on every request, so
toolsChanged() is false, no series restarts, and there is no repeated cost.
test/mount.test.mjs pins all three facts.
License
Apache-2.0