Sandboxing DeepSeek Harness with Bubblewrap
Running an AI coding agent inside a constrained Linux environment with a fake home and limited filesystem access.
Context
Where this started
I wanted to try DeepSeek Harness as an agentic coding environment on Arch Linux.
My main concern was not whether Harness itself was malicious. The more realistic threat model was broader:
- Harness is an agent capable of running shell commands.
- It executes model-generated actions.
- It installs and depends on a large npm dependency tree.
- Prompt injection or a compromised dependency could potentially cause unexpected file access.
- I did not want an agent working on one project to be able to read unrelated personal files from my home directory and potentially transmit them to a remote model provider.
Running Harness directly from:
cd ~/Projects/some-project
dsh
does not inherently restrict it to that directory. A normal Linux process running as my user can generally access anything my user can access.
So, being the paranoid me (:D), that was the problem I wanted to solve. At the same time, I did not want to overengineer the setup with a full VM, custom SELinux policies, containers with complicated networking, or a separate operating system, you know.
I already had the necessary tooling:
Node.js
pnpm
bubblewrap
git
ripgrep
The central idea therefore became:
Run DeepSeek Harness normally, but put the entire Harness process inside an outer Bubblewrap sandbox whose filesystem view contains only the current project, a dedicated fake home, and the minimum system files required to operate.
Harness would still retain its own internal sandbox. dsh-safe would add an independent security boundary underneath it.
Objective
What I wanted to figure out
Alright, the final setup had several concrete requirements.
Filesystem isolation
If I launch:
cd ~/Projects/tenno-ledger # <-- Take a look at this in PROJECTS!!!
dsh-safe
Harness should be able to access:
/home/raul/Projects/tenno-ledger
but not:
/home/raul/.ssh
/home/raul/.gnupg
/home/raul/Documents
/home/raul/Downloads
/home/raul/.config
/home/raul/Projects/other-project
The real home directory should simply not exist from Harness’s point of view, because DeepSeek I won’t give you my secrets :P
Preserve the real project path
Instead of mounting the project as an artificial path such as:
/workspace
I wanted it to remain:
/home/raul/Projects/tenno-ledger
This would avoids breaking:
- virtual environments;
- scripts containing absolute paths;
- development tooling;
- project configuration expecting the original path.
Writable project, read-only system
Harness needs write access to the project, but it should not be able to modify the host system.
The intended model was:
project read/write
fake HOME read/write
/tmp temporary read/write
/usr read-only
/etc read-only
real HOME invisible
other projects invisible
Environment isolation
Secrets accidentally present in my shell environment should not automatically propagate to the agent.
For example:
SSH_AUTH_SOCK
cloud credentials
API tokens
desktop session variables
should disappear unless deliberately provided.
Defense in depth
Harness already provides modes such as:
workspace-write
danger-full-access
I did not want the entire security model to depend on those controls. Even if I explicitly approved a Harness escalation to danger-full-access, the outer Bubblewrap sandbox should remain in force.
Safe npm installation and updating
Lifecycle scripts from npm dependencies should also run inside a sandbox.
Updates should be:
- pinned to a specific version;
- installed separately;
- verified;
- tested;
- swapped into production only afterwards;
- easy to roll back.
Approach
What I tried
1. Preparing an isolated runtime
I created a dedicated location for Harness:
mkdir -p ~/.local/share/dsh-safe/{app,home}
mkdir -p ~/.local/bin
The resulting structure became:
~/.local/share/dsh-safe/
├── app/
└── home/
app/ contains the installed Harness runtime.
home/ becomes Harness’s persistent fake home.
The runtime was deliberately installed locally rather than globally.
2. Verifying Bubblewrap on Arch
The first Bubblewrap test failed (of course):
bwrap: execvp /usr/bin/bash: No such file or directory
even though /usr/bin/bash obviously existed. The reason was Arch’s merged-/usr filesystem layout.
bash uses:
/lib64/ld-linux-x86-64.so.2
as its ELF interpreter, while the initial sandbox only mounted /usr.
The correct sandbox therefore needed Arch’s normal compatibility symlinks:
--symlink usr/bin /bin
--symlink usr/bin /sbin
--symlink usr/lib /lib
--symlink usr/lib /lib64
After adding them:
bwrap OK
PID: 2
(yay!) This also confirmed that PID namespaces worked correctly.
3. Installing Harness without exposing my home directory
The initial pinned version was:
@deepseek-ai/dsh@0.1.2-rc.1
I created a minimal package manifest:
{
"name": "dsh-safe-runtime",
"private": true,
"dependencies": {
"@deepseek-ai/dsh": "0.1.2-rc.1"
}
}
The npm installation itself was then performed inside Bubblewrap.
Conceptually, the installer saw:
/usr read-only
/etc read-only
/app writable
/home/dsh dedicated fake home
/tmp temporary
/proc
/dev
It did not see /home/raul. This mattered because npm lifecycle scripts are executable code.
A compromised installation script trying something like:
cat /home/raul/.ssh/id_ed25519
would simply encounter a nonexistent path.
I also used:
--clearenv
so installation scripts would not inherit credentials from my login session.
4. Explicit npm build-script approval
pnpm refused to automatically execute several lifecycle scripts:
@deepseek-ai/dsh-subprocess-local
@google/genai
koffi
node-pty
protobufjs
Instead of approving everything blindly, I compared this with the policy maintained by the DeepSeek Harness project. The resulting policy was:
allowBuilds:
'@deepseek-ai/dsh-subprocess-local': true
'@google/genai': false
koffi: true
node-pty: true
protobufjs: false
The approved scripts were executed inside the installation sandbox. This was one of the useful benefits of using recent pnpm versions: unexpected native/build lifecycle execution became explicit rather than invisible.
5. Verifying package identity and integrity
Before trusting the npm artifact, I compared its metadata.
For example:
pnpm view @deepseek-ai/dsh@VERSION \
name version repository.url homepage dist.integrity dist.tarball \
--json
I checked that:
- the package was named
@deepseek-ai/dsh; - the version was the one I requested;
- the repository pointed to
deepseek-ai/deepseek-harness; - the registry tarball came from npm;
- the SHA-512
dist.integritymatched the value recorded inpnpm-lock.yaml.
For the later 0.1.5-rc.1 update, npm and the lockfile both contained the same SHA-512 digest. This does not magically prove every source file has a perfect reproducible-build chain from GitHub to npm, but it gives strong assurance that:
- I selected the intended package;
- pnpm downloaded the expected registry artifact;
- the artifact matches the integrity value locked locally.
6. Testing the runtime read-only
Before building the launcher, I tested whether Harness could execute with the entire application directory mounted read-only:
--ro-bind "$APP" /opt/dsh
Then:
/opt/dsh/node_modules/.bin/dsh --version
aaaaaaand worked correctly.
That confirmed the installed runtime did not require write access to its own program files. This allowed the final design to treat /opt/dsh as immutable while running.
7. Building the dsh-safe launcher
The launcher lives at:
~/.local/bin/dsh-safe
It identifies the current Git repository using:
git rev-parse --show-toplevel
I deliberately made being inside a Git repository a requirement. This is not required by Bubblewrap; it is simply a guardrail that gives the launcher an unambiguous workspace root. I could change this, tho, idk.
The launcher also refuses obviously dangerous workspace roots such as:
/
/home
$HOME
$HOME/Projects
because accidentally mounting one of those would defeat most of the isolation.
The core idea is:
host filesystem
│
└── Bubblewrap namespace
│
├── /usr RO
├── /etc RO
├── /tmp private tmpfs
├── /opt/dsh Harness runtime, RO
├── /home/dsh fake HOME, RW
│
└── /home/raul/Projects/current-project
project, RW
The real /home/raul is never mounted. To preserve the real project path, dsh-safe reconstructs only the required parent directory hierarchy:
/home
/home/raul
/home/raul/Projects
These are empty directories inside the namespace. Then the actual project is mounted at:
/home/raul/Projects/project-name
The result looks authentic to development tools while exposing nothing else.
8. Runtime environment sanitization
The launcher uses:
--clearenv
and reconstructs only a small controlled environment:
HOME=/home/dsh
DSH_HOME=/home/dsh/.dsh
XDG_CACHE_HOME=/home/dsh/.cache
XDG_CONFIG_HOME=/home/dsh/.config
XDG_DATA_HOME=/home/dsh/.local/share
PATH=/usr/local/bin:/usr/bin:/bin
USER=dsh
LOGNAME=dsh
DSH_PERMISSION_MODE=workspace-write
DSH_TELEMETRY_DISABLED=1
A diagnostic shell confirmed that variables from my KDE/session environment did not leak through.
9. Validating actual filesystem isolation
I added a diagnostic mode:
dsh-safe --shell
Inside it:
pwd
returned the real project path, nice.
But attempts to access personal paths failed:
ls ~/.ssh
ls /home/raul/.ssh
ls /home/raul/Documents
ls /home/raul/Downloads
ls /home/raul/.config
All returned:
No such file or directory
Writing inside the project worked:
touch .dsh-safe-write-test
rm .dsh-safe-write-test
Writing to the host system failed:
touch /usr/dsh-test
touch /etc/dsh-test
with:
Read-only file system
At this point the outer filesystem boundary was empirically verified, nice pt.2 :)
10. Testing Harness’s internal sandbox
Harness itself also uses a sandbox.
I tested this independently by asking the agent to run:
pwd
touch ./dsh-inner-workspace-test
rm ./dsh-inner-workspace-test
touch /home/dsh/dsh-inner-outside-test
touch /usr/dsh-inner-test
With the actual project selected as the workspace:
/home/raul/Projects/tenno-ledger
the expected behavior occurred. Writes inside the project succeeded. Writes to /home/dsh were rejected by Harness’s workspace-write policy. Harness then offered an explicit escalation:
danger-full-access
I approved the harmless test once.
The interesting result was:
workspace-write
↓ blocked
danger-full-access
↓ allowed by Harness
outer Bubblewrap
↓
/usr still read-only
Even after Harness was granted its own version of “full access” (poor fool):
touch /usr/dsh-inner-test
still failed with:
Read-only file system
That demonstrated the defense-in-depth property I wanted. Harness’s permissions and dsh-safe are independent boundaries.
11. Persistent fake home
The fake home is deliberately persistent:
~/.local/share/dsh-safe/home
This allows Harness to retain:
- sessions;
- application settings;
- model settings;
- credentials;
- other application state.
Meanwhile, the host’s actual home remains invisible. One side effect is that the workspace picker initially opens at:
/home/dsh
rather than the project, which was kinda annoying. The real project is still available at its preserved absolute path, e.g.:
/home/raul/Projects/st-forgottenRealms
and can be selected manually.
12. API key handling: finding and fixing a mistake
The original launcher prompted for the DeepSeek API key:
read -r -s -p "DeepSeek API key: " DEEPSEEK_API_KEY
and passed it into Bubblewrap using:
--setenv DEEPSEEK_API_KEY "$DEEPSEEK_API_KEY"
This looked reasonable at first. It was not. Of course it was not. Running:
pgrep -af 'dsh|bwrap'
revealed that the API key was present directly in the Bubblewrap command line. That means it was observable through the process table. This was an important mistake. The key was rotated, and I removed all DEEPSEEK_API_KEY handling from the launcher. The Harness already provides a credential store inside:
$DSH_HOME/.credentials.yaml
which in my configuration maps to:
host:
~/.local/share/dsh-safe/home/.dsh/.credentials.yaml
sandbox:
/home/dsh/.dsh/.credentials.yaml
The file has:
0600
permissions. I now store the DeepSeek key through Harness’s model/provider settings instead of putting it on the Bubblewrap command line. A later:
pgrep -af 'bwrap|/opt/dsh/node_modules/.bin/dsh'
confirmed that the key no longer appears in process arguments. This is significantly better. One caveat remains: 0600 protects the credential from other Unix users, not from Harness itself. Harness runs as my UID and deliberately has access to its fake home. The outer sandbox protects my unrelated secrets from Harness; it cannot protect a secret that Harness must itself possess to authenticate to DeepSeek.
13. Updating Harness safely
I initially installed:
0.1.2-rc.1
Later DeepSeek released:
0.1.5-rc.1
which added the new:
DeepSeek-V41-Flash
(the cheap GOAT!) model to the selector.
Rather than updating the working installation in place, I created:
~/.local/share/dsh-safe/app-new
and installed the new pinned version there.
I repeated:
- npm metadata verification;
- SHA-512 integrity comparison;
- build-script policy verification;
- read-only runtime test;
- UI/model selector test.
Only afterwards did I swap the installations:
app -> app-old
app-new -> app
The final state became:
app -> 0.1.5-rc.1
app-old -> 0.1.2-rc.1
Because dsh-safe always points at:
~/.local/share/dsh-safe/app
the launcher itself did not need to change. Rollback remains trivial. Annoying? Yes. But trivial.
Observations
What happened
Bubblewrap is a good fit for this threat model
The main benefit is not “making DeepSeek safe”. It is reducing what DeepSeek can possibly see, independently of its own intentions. If a process never receives a mount for:
/home/raul/.ssh
then filesystem access controls inside the application become far less important for protecting that directory. This is a stronger model than simply instructing an agent:
Do not read files outside the project.
danger-full-access is relative, not absolute
Harness’s danger-full-access initially sounds alarming. But inside dsh-safe, it really means:
Full access to everything visible inside the outer Bubblewrap namespace.
That namespace still does not contain my real home. This distinction is important.
The stack is:
Harness workspace-write
↓
Harness danger-full-access
↓
outer Bubblewrap boundary
↓
host system
An approval can relax the first boundary. It does not automatically relax the second.
The fake home is both useful and sensitive
A persistent fake home gives Harness a place to store state without exposing my actual home. It also becomes one of the few persistent locations available to the agent.
Therefore:
/home/dsh
should be treated as belonging to Harness, not as a place for unrelated secrets.
npm lifecycle scripts deserve explicit attention
The installation pulled roughly 500 packages (damn). That makes manually reviewing every dependency unrealistic.
The useful controls were instead:
- pinned top-level version;
- lockfile;
- integrity hashes;
- isolated installation;
- blocked lifecycle scripts by default;
- explicit approval only for known required build dependencies.
This does not eliminate supply-chain risk. It substantially limits its blast radius, which I like.
A sandbox should be tested, not merely assumed
Several of the most useful discoveries came from intentionally trying to break assumptions:
ls /home/raul/.ssh
touch /usr/test
env
pwd
and from intentionally approving a harmless Harness escalation. That produced evidence about the actual boundary rather than just relying on configuration files.
Process arguments are an underrated secret leak
The biggest mistake in the first launcher was not a filesystem permission issue.
It was this:
secret -> command line argument -> visible in process table
read -s only prevents terminal echo. It does not help if the secret later becomes part of argv. That was fixed by moving provider authentication into Harness’s own credential store.
Nested sandboxing can produce surprising process behavior
Harness itself creates isolated execution environments for commands. This became visible while running long OCR jobs. A command launched with normal:
nohup ... &
could die when the tool-call sandbox disappeared because the sandbox used mechanisms such as:
--die-with-parent
Harness’s own background-job mechanism was the correct way to keep those workloads alive. Separate tool calls can also use separate PID namespaces, meaning a later ps inside one sandbox may not see a process belonging to another. The host, however, can still inspect the child namespaces and processes.
The system supports a useful multi-agent model
Once the sandboxing was working, Harness’s subagents became much more interesting. The main agent can launch several subagents, continue independent work, receive their completion notifications, and resume automatically. The important security consequence is that all of them still inherit the same outer filesystem boundary. A fan-out of seven agents does not suddenly mean seven agents with access to my entire home. They all operate inside the same deliberately restricted universe.
Takeaways
What I learned
The final setup is deliberately simple:
DeepSeek API
↑
DeepSeek Harness
│
├── internal workspace-write sandbox
│
└── internal approval system
│
▼
outer Bubblewrap
│
├── project RW
├── fake HOME RW
├── private /tmp
├── /usr RO
├── /etc RO
└── real HOME absent
The most important lesson is that I do not need to completely trust an AI coding agent in order to use it productively. I can instead constrain its environment so that the consequences of unexpected behavior are limited. dsh-safe does not make Harness invulnerable, nor does it solve every possible security problem. In particular:
- Harness still has network access;
- it can transmit files that are inside the selected project;
- it can access credentials deliberately stored in its fake home;
- a kernel/Bubblewrap vulnerability would be outside this threat model;
- approving dangerous actions still deserves attention.
But it changes the default from:
agent runs as me and can see almost everything I can
to:
agent runs as me inside a filesystem namespace containing
only the resources I intentionally gave it
For my use case, that is the important boundary. The final setup currently uses:
DeepSeek Harness 0.1.5-rc.1
runtime read-only
workspace current Git repository only
system /usr + /etc read-only
real home not mounted
persistent fake home ~/.local/share/dsh-safe/home
credentials Harness store, mode 0600
environment cleared and reconstructed
telemetry disabled
default permission mode workspace-write
rollback previous runtime kept as app-old
The resulting workflow is still straightforward:
cd ~/Projects/my-project
dsh-safe
which was ultimately the goal: a security boundary strong enough to matter, without making the tool unpleasant to use. Am I using it right now? Hell no, but it was fun to experiment :)