All lab entries
Experiment

Sandboxing DeepSeek Harness with Bubblewrap

Running an AI coding agent inside a constrained Linux environment with a fake home and limited filesystem access.

  • Linux
  • Security
  • AI Agents
  • Sandboxing

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.

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.

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.integrity matched the value recorded in pnpm-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:

  1. I selected the intended package;
  2. pnpm downloaded the expected registry artifact;
  3. 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.

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.

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 :)

All lab entries