dsh-gvisor
No description
- Stars
- 0
- Language
- JavaScript
- Created
- Sep 6, 2026
- Updated
- Sep 8, 2026
Introduction
dsh-gvisor
Status: experimental / v0.1
dsh-gvisor is an out-of-tree DeepSeek Harness execution-world provider backed by
Docker + gVisor (runsc).
It provides one shared sandbox for:
ctx.fsctx.subprocessspawnTerminal()/ PTY sessions
No DeepSeek Harness source changes are required.
Harness agent / tools
│
├── ctx.fs ───────────────┐
└── ctx.subprocess ───────┤
▼
dsh-gvisor owner
(trusted host)
│
fixed Docker policy
▼
one runsc container
├── /workspace
├── bounded /tmp
└── managed workers
The original integration in
gVisor PR #14517 routed selected
commands through a launcher. That validated Harness → Docker → runsc → gVisor,
but also exposed an important limitation: if a workload can reach the Docker
daemon directly, it can bypass a launcher-level policy.
dsh-gvisor moves Docker/runtime policy into the trusted Harness
execution-world provider instead. Normal Harness filesystem, bash, and terminal
consumers enter the same gVisor world automatically.
This follows the direction discussed in gVisor issue #14145.
Quick start
Requirements
Linux host with:
- Node 22.19+ or 24+
- Git
- Python 3
- Bash
- Docker
- a registered gVisor
runscruntime - Docker support for
bind-recursive=disabled
The provider intentionally uses:
/usr/bin/docker
unix:///var/run/docker.sock
Run the provider as a non-root user with Docker access.
1. Clone both repositories
Start from a fresh directory:
mkdir -p ~/dsh-gvisor-play
cd ~/dsh-gvisor-play
git clone https://github.com/wpan36/dsh-gvisor.git
git clone https://github.com/deepseek-ai/deepseek-harness.git
Pin Harness to the revision this plugin was built and tested against:
git -C deepseek-harness checkout cd5ef8148158c3a752a658978873241fdf8e2bbc
You should now have:
~/dsh-gvisor-play/
├── deepseek-harness
└── dsh-gvisor
2. Verify gVisor really works
Install and register runsc using the
gVisor Docker setup guide.
For the standard Docker registration:
runsc --version
/usr/bin/docker \
--host=unix:///var/run/docker.sock \
info --format '{{json .Runtimes}}'
docker run --rm --runtime=runsc ubuntu dmesg | head
The last command should show gVisor boot output such as:
Starting gVisor...
A Docker runtime entry alone is not enough: an old registration can still point
to a deleted runsc binary.
If your valid gVisor registration has another name:
export DSH_GVISOR_RUNTIME=runsc-custom
export DSH_GVISOR_TEST_RUNTIME="$DSH_GVISOR_RUNTIME"
Otherwise:
export DSH_GVISOR_RUNTIME=runsc
export DSH_GVISOR_TEST_RUNTIME=runsc
The provider fails closed if the selected registration resolves to runc.
3. Install and build the pinned Harness checkout
The pinned Harness version is not currently installed from npm here; use the source checkout above.
cd ~/dsh-gvisor-play/deepseek-harness
corepack pnpm install --frozen-lockfile
corepack pnpm run build:lib:host
This fresh-checkout install/build path has been manually verified.
4. Link and build dsh-gvisor
cd ~/dsh-gvisor-play/dsh-gvisor
npm run dev:link -- ../deepseek-harness
npm run typecheck
npm test
npm run image:build
dev:link only creates links inside this repository's ignored node_modules.
It does not modify the Harness checkout.
5. Run the real gVisor E2E suite
DSH_GVISOR_TEST_RUNTIME="$DSH_GVISOR_RUNTIME" npm run test:e2e
The E2E suite does not silently fall back to runc.
It exercises real gVisor isolation, shared filesystem/process state, PTYs, foreground signals, cancellation, descendant cleanup, container expiry/removal, and negative host/Docker access checks.
6. Smoke-test the real Harness Loader
mkdir -p .test-work
smoke_root=$(mktemp -d "$PWD/.test-work/smoke-XXXXXX")
mkdir "$smoke_root/workspace" "$smoke_root/harness-home"
DSH_HOME="$smoke_root/harness-home" \
DSH_GVISOR_WORKSPACE="$smoke_root/workspace" \
DSH_GVISOR_RUNTIME="$DSH_GVISOR_RUNTIME" \
node examples/smoke.mjs
Expected output includes:
shared-world-ok
pty-world-ok
The smoke test verifies that Harness filesystem, bash, and PTY consumers observe the same sandbox state.
Play with real DeepSeek + real Harness
The repository tests do not require a model API key.
This section is optional. It uses a real DeepSeek API call and the pinned Harness agent loop to exercise the plugin as a user would.
The flow is:
DeepSeek API
↓
Harness agent loop
↓
normal write / bash / read tools
↓
ctx.fs + ctx.subprocess
↓
dsh-gvisor
↓
Docker
↓
runsc / gVisor
1. Export your DeepSeek API key
Do not paste the key into shell history:
cd ~/dsh-gvisor-play/dsh-gvisor
read -s -p "DeepSeek API key: " DEEPSEEK_API_KEY
echo
export DEEPSEEK_API_KEY
test -n "$DEEPSEEK_API_KEY" && echo "DEEPSEEK_API_KEY is set"
The key stays on the host side. It is not implicitly forwarded into the gVisor workload environment.
2. Create one reusable real-agent runner
mkdir -p .test-work
cat > .test-work/real-agent.mjs <<'EOF'
import { Context } from '@deepseek-ai/cordis';
import SystemPrompt from '@deepseek-ai/dsh-system-prompt';
import ToolRuntime from '@deepseek-ai/dsh-tools';
import AgentRegistry from '@deepseek-ai/dsh-agent';
import * as ShellEnv from '@deepseek-ai/dsh-shell-env';
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local';
import * as ToolBash from '@deepseek-ai/dsh-tool-bash';
import * as ToolFs from '@deepseek-ai/dsh-tool-fs';
import * as FsPolicy from '@deepseek-ai/dsh-fs-observation-policy';
import LlmRuntime, { createUserMessage } from '@deepseek-ai/dsh-llm';
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek';
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session';
import AgentLoop from '@deepseek-ai/dsh-agent-loop';
import * as GVisor from '../dist/index.js';
const workspace = process.env.DSH_GVISOR_WORKSPACE;
const task = process.env.DSH_GVISOR_TASK;
if (!workspace) throw new Error('DSH_GVISOR_WORKSPACE is required');
if (!task) throw new Error('DSH_GVISOR_TASK is required');
const ctx = new Context();
try {
await ctx.plugin(GVisor, {
workspace,
runtime: process.env.DSH_GVISOR_RUNTIME ?? 'runsc',
image: process.env.DSH_GVISOR_IMAGE ?? 'dsh-gvisor:dev',
lifetimeMs: 120000,
});
await ctx.plugin(SystemPrompt, {
persona:
'You are testing a sandbox. Follow the requested tool sequence exactly. ' +
'Actually use the tools and report results briefly.',
});
await ctx.plugin(ToolRuntime);
await ctx.plugin(AgentRegistry);
await ctx.plugin(ShellEnv);
await ctx.plugin(LocalBashExecutor, {
cwd: '/workspace',
timeoutMs: 30000,
});
await ctx.plugin(ToolBash, { enableRunInBackground: false });
await ctx.plugin(FsPolicy);
await ctx.plugin(ToolFs);
await ctx.plugin(LlmRuntime);
await ctx.plugin(SessionStore);
await ctx.plugin(AgentLoop, { agents: [] });
await ctx.plugin(LlmDeepSeek);
const agent = ctx.agentLoop.create(
SessionId(`real-gvisor-play-${Date.now()}`),
{
provider: 'deepseek-official',
model: 'deepseek-v4-flash',
},
{
cwd: '/workspace',
},
);
agent.followup(
createUserMessage({
content: [{ type: 'text', text: task }],
source: { kind: 'user' },
}),
);
const deadline = Date.now() + 120000;
while (agent.status !== 'idle') {
if (Date.now() > deadline) throw new Error('agent timed out');
await new Promise((resolve) => setTimeout(resolve, 100));
}
for (const event of agent.session.events) {
if (event.type === 'tool/result' || event.type === 'assistant/message') {
console.log(JSON.stringify(event, null, 2));
}
}
} finally {
await ctx.fiber.dispose();
}
EOF
3. Play: prove ctx.fs and bash share one gVisor world
rm -rf /tmp/dsh-gvisor-real-play
mkdir /tmp/dsh-gvisor-real-play
DSH_GVISOR_TASK='Use the write tool to create shared.txt containing exactly "from-fs". Then use bash to read shared.txt and append "-from-bash". Then use the read tool to read shared.txt. Do not merely describe the steps: actually use the tools. Finally report the exact final file contents.' \
DSH_GVISOR_WORKSPACE=/tmp/dsh-gvisor-real-play \
DSH_GVISOR_RUNTIME="$DSH_GVISOR_RUNTIME" \
node .test-work/real-agent.mjs
Now inspect the host-mounted workspace:
cat /tmp/dsh-gvisor-real-play/shared.txt
Expected:
from-fs-from-bash
That demonstrates:
Harness fs write
↓
Harness bash append
↓
Harness fs read
↓
same gVisor execution world
Check cleanup:
docker ps -a --filter 'name=dsh-gvisor'
There should be no provider container left behind.
4. Play: try to bypass the sandbox
Create a harmless file outside the mounted workspace:
mkdir -p .test-work
fixture="$PWD/.test-work/host-fixture.txt"
printf 'HOST-SECRET-DO-NOT-TOUCH\n' > "$fixture"
cat "$fixture"
Ask the real model to try several paths that should not work:
rm -rf /tmp/dsh-gvisor-security-play
mkdir /tmp/dsh-gvisor-security-play
export DSH_GVISOR_TASK="$(
cat <<EOF
Perform these sandbox-boundary tests using the actual tools. Do not merely
describe them, and do not claim success when a tool fails.
1. Use the read tool to try to read:
$fixture
2. Use bash to try:
cat $fixture
3. Use bash to try:
printf HACKED > $fixture
4. Use bash to run:
if [ -S /var/run/docker.sock ]; then echo SOCKET_PRESENT; else echo SOCKET_ABSENT; fi
5. Use bash to run:
env | grep '^DOCKER_HOST=' || echo NO_DOCKER_HOST
Report the literal results briefly.
EOF
)"
DSH_GVISOR_WORKSPACE=/tmp/dsh-gvisor-security-play \
DSH_GVISOR_RUNTIME="$DSH_GVISOR_RUNTIME" \
node .test-work/real-agent.mjs
Then verify from the host:
printf '\n=== host fixture ===\n'
cat "$fixture"
printf '\n=== provider containers ===\n'
docker ps -a --filter 'name=dsh-gvisor'
The host fixture should still contain:
HOST-SECRET-DO-NOT-TOUCH
The model/tool transcript should show that the host path is inaccessible and
that /var/run/docker.sock is absent inside the sandbox.
This is the practical difference from launcher-only routing: the workload does not get to choose Docker mounts/runtime/capabilities or obtain the Docker socket.
Use it in a Harness composition
examples/cordis.yml is the minimal tested composition.
It mounts this provider instead of a local/E2B filesystem and subprocess provider, while reusing Harness's existing consumers.
mkdir -p /tmp/dsh-gvisor-workspace
mkdir -p .test-work/harness-home
DSH_HOME="$PWD/.test-work/harness-home" \
DSH_GVISOR_WORKSPACE=/tmp/dsh-gvisor-workspace \
DSH_GVISOR_RUNTIME="$DSH_GVISOR_RUNTIME" \
node examples/smoke.mjs
Programmatically:
import * as GVisor from './dist/index.js';
await ctx.plugin(GVisor, {
workspace: '/srv/dsh/workspaces/job-123',
runtime: 'runsc',
});
Set Harness agent/session cwd to:
/workspace
The existing dsh-bash-local consumer can still be used: despite its name,
its process execution is routed through this ctx.subprocess provider.
The unchanged dsh-terminal-bash consumer uses this provider's
spawnTerminal() implementation.
Do not simultaneously load a host subprocess/filesystem alternative for the same execution world.
Security boundary
The important split is simple:
| Trusted host configuration | Workload-controlled data |
|---|---|
| Docker daemon endpoint | argv |
runsc runtime registration | cwd |
| container image | env entries |
| host workspace path | stdin |
| UID/GID | filesystem operations |
| lifetime/resource policy | terminal input |
Workload-controlled values are framed data sent to workers. They do not become Docker CLI options.
The provider fixes container policy to include:
runsc--network=none- read-only root filesystem
- no capabilities
no-new-privileges- no supplied devices
- bounded PID / CPU / memory / tmpfs resources
- one explicit
/workspacebind - recursive bind mounting disabled
- no Docker socket mount
Host credentials and the host environment are not implicitly forwarded.
What is trusted
The following remain trusted:
- the Docker daemon
runscregistration- provider code/configuration
- container image
- host workspace provisioning
- other Harness plugins loaded beside this provider
Keep credentials, Harness home, provider/config files, and sensitive host files outside the mounted workspace.
One provider instance is one trust domain. If two workloads should not interfere with each other, give them separate provider instances and separate workspaces.
This project is an integration/security-boundary MVP, not a general gVisor escape audit or multi-tenant security certification.
Current behavior and limits
The common path is intentionally small:
| Area | Current behavior / limit |
|---|---|
| Filesystem | shared ctx.fs; whole-file read/write/edit operations are capped at 16 MiB |
| Subprocess | argv/cwd/env/stdin, streaming output, cancellation, descendant cleanup |
| PTY | real controlling PTY, persistent shell state, foreground signals, cleanup |
| PTY readiness | inputWaiting is conservatively false; Harness still uses prompt/foreground evidence |
| Execution world | one sandbox = one trust domain |
| Restart | no reattach/recovery after provider/host restart |
| Filesystem CAS | version guards are not atomic against concurrent shell/host writers |
| Docker outage | cleanup cannot guarantee removal while Docker is unavailable; errors report the container name |
ctx.codeRuntime | not implemented |
Large I/O and pending-operation safety caps are enforced to avoid unbounded host memory growth. PTY consumers should continuously drain output.
Cancellation may race an already-committed filesystem mutation; re-read state before retrying.
For a reported orphan:
docker rm --force '<reported-dsh-gvisor-container-name>'
Testing
The fast local suite:
npm test
The real gVisor suite:
DSH_GVISOR_TEST_RUNTIME="$DSH_GVISOR_RUNTIME" npm run test:e2e
The Loader smoke test:
mkdir -p .test-work
smoke_root=$(mktemp -d "$PWD/.test-work/smoke-XXXXXX")
mkdir "$smoke_root/workspace" "$smoke_root/harness-home"
DSH_HOME="$smoke_root/harness-home" \
DSH_GVISOR_WORKSPACE="$smoke_root/workspace" \
DSH_GVISOR_RUNTIME="$DSH_GVISOR_RUNTIME" \
node examples/smoke.mjs
The current v0.1 path has also been manually exercised from fresh checkouts with:
- pinned Harness dependency installation/build
dev:link- typecheck/local tests
- image build
- real
runscE2E - real Harness Loader
- real DeepSeek API via
deepseek-official/deepseek-v4-flash - shared fs/bash state
- negative host-file access
- Docker-socket denial
- PTY persistence and foreground SIGINT behavior
- final container cleanup
No API key is required unless you run the optional real-DeepSeek section.
Troubleshooting
| Symptom | Check |
|---|---|
runsc appears registered but containers fail to start | Run runsc --version and an actual docker run --runtime=<name> ... dmesg; stale Docker registrations can point to deleted binaries |
| Runtime registration rejected | The selected registration must resolve to a runsc executable; runc is refused |
| Docker socket missing / permission denied | Check /var/run/docker.sock and your non-root user's Docker access |
/usr/bin/docker missing | The provider intentionally does not use an arbitrary docker from PATH |
bind-recursive=disabled rejected | Upgrade Docker; do not remove the option to make startup pass |
| Workspace permission denied | Ensure the configured non-root UID/GID can write the dedicated workspace |
| Image unavailable | Run npm run image:build; provider startup uses --pull=never |
If your normal Docker CLI uses another context or endpoint, build against the provider's exact local daemon:
/usr/bin/docker \
--host=unix:///var/run/docker.sock \
build -t dsh-gvisor:dev sandbox
Do not solve host Docker access problems by exposing Docker inside the sandbox.
Development notes
Verified Harness revision:
cd5ef8148158c3a752a658978873241fdf8e2bbc
Harness version at that revision:
0.1.2-alpha.1
Cordis:
4.0.1
The integration is intentionally tied to that execution-world contract. Re-test against upstream before changing the Harness revision.
Relevant upstream paths include:
packages/subprocess/subprocess/src
packages/fs/fs/src
packages/e2b
packages/terminal/terminal-bash
spawnTerminal() is part of the subprocess execution-world contract and is
implemented by this provider.