Claude Code isolation for lazy people

https://github.com/julibeg/clappt

Agentic coding capabilities are improving at a pace that is almost impossible to keep up with; this applies to individual users as well as the ecosystem as a whole, especially when it comes to security and privacy infrastructure.

I’m still relatively early in my Claude Code journey1, but one thing that bugged me from the start is that, even though catastrophic events can be prevented with strict permission management and version control, it is surprisingly difficult to avoid data leakage to the LLM (and I’m so pedantic that even my Linux username being included in a tool call output is bothering me, let alone my personal name or email addresses of myself / collaborators, etc.).

To put my paranoia to rest, I started running Claude Code in an Apptainer container for more isolation and added several hooks that intercept and scrub the output of tool calls before the LLM sees them. While far from being polished, the usability of my setup improved over time so that by now it might be good enough to share.

⚠️ IMPORTANT WARNING ⚠️
The below does not provide real security. Things like prompt injection attacks are nigh impossible to fully protect against at this stage. clappt just picks some of the lowest-hanging fruits with respect to security and privacy. It is no replacement for good secrets management or restricting agent permissions. Only run in YOLO mode on a machine you truly don’t care about.

The main goal of clappt

  1. Run Claude Code in an isolated environment to reduce the risk of modifying or deleting files outside the project directory: Restrictive permissions should already make this unlikely, but running in a container with only the project directory mounted adds an extra layer of protection.
  2. Use hooks to avoid sending certain data to the LLM provider: Tool call outputs commonly contain username, hostname, personal name + email (git log) and possibly other sensitive data. We can use Claude Code’s hooks system to intercept tool call outputs and scrub secrets from them before they are sent to the model.

To keep things simple, I kept the container minimal and instead hacked together the wrapper script to make all the dev tools the agent might need available inside the container (bound from the host instead of installed in the container itself). I mostly use micromamba (I know I really should switch to uv) and Rust, but modifying the wrapper to support other setups should be straightforward.

Why Apptainer?

  • Claude Code actually does come with a sandbox feature, but it needs dependencies on Linux (bubblewrap and socat) that can only be installed with admin perms (which I don’t have on some of the machines I work on).
  • Docker and Podman offer arguably better sandboxing than Apptainer/Singularity, but they also need elevated permissions – Docker needs them to install and run, Podman only to install, but it does need them.
  • Apptainer/Singularity can be installed and run with unprivileged permissions (e.g. also on a shared cluster). It is also quite common in the scientific/HPC community and might already be installed in your environment.

‼️ Caveat️ ‼️
On modern Ubuntu (24.04+), truly unprivileged Apptainer/Singularity install is no longer possible due to AppArmor restrictions.

The container image

As mentioned above, the image is kept minimal. It only contains a couple basic utilities that I find myself (or the agent) using often enough that I wanted them available without having to bind-mount from the host:

  • basic CLI tools (git, bash, jq, etc.)
  • PDF/image manipulation utilities (poppler-utils, imagemagick)
  • Playwright runtime dependencies (Playwright itself we bind from the host)
  • shc to compile shell scripts into non-transparent binaries

The %runscript checks for an EXTRA_PATHS env var and prepends it to PATH if set:

if [ -n "$EXTRA_PATHS" ] ; then
    echo "INFO: Adding '$EXTRA_PATHS' to PATH"
    export PATH="$EXTRA_PATHS:$PATH"
fi

This makes executables in bound locations (for example, a conda env’s bin/ or ~/.cargo) available inside the container.

What is shc?

https://github.com/neurobin/shc

The hooks for sanitizing tool output before it reaches the LLM expect a scrub executable. If this was just a plain shell script, an agent could read it and see the secrets it’s meant to hide (which would defeat the purpose).

Using shc to compile that script into a binary raises the bar: just cat-ing it won’t show the secrets and extracting them now requires deliberate reverse-engineering. This obfuscation is not perfect though. A determined actor (or agent) can definitely crack it open, but it would need to install tools for dissecting the binary, which should be difficult if restrictive permissions are in place.

The wrapper script

The main job of the apptainer run wrapper is mostly PATH and environment plumbing.

1) Mask user and home context

In contrast to Docker / Podman the user inside an Apptainer container is the same as the host user. To mask the user name and home directory, we override the corresponding env vars in the container and the wrapper script also overlays /etc/passwd and /etc/group with dummy files so the visible identity becomes a neutral user2:

echo "user:x:$(id -u):$(id -g):User:/home/user:/bin/bash" >"$tmp_passwd"
echo "user:x:$(id -g):" >"$tmp_group"
...
apptainer run \
    ...
    "--bind" "$tmp_passwd:/etc/passwd:ro"
    "--bind" "$tmp_group:/etc/group:ro"
    ...

2) Mask project path

clappt hashes the path to the host working directory and binds it inside the container under /work/<hash>/<project>:

work_dir_name=$(basename "$PWD")
work_dir_hash=$(printf "%s" "$PWD" | sha256sum | cut -c1-12)
masked_work_dir="/work/${work_dir_hash}/${work_dir_name}"

This way tool calls do not casually leak the real host path.

3) Keep host state, but only what is needed

The script bind-mounts Claude’s config/state and some utilities. Additionally, it optionally mounts:

  • micromamba root + executable (if present)
  • bin directory of the currently active conda environment and of an environment called "cli-utils" if there is one 3
  • Rust (~/.cargo, ~/.rustup) if available
  • Playwright browser cache if Playwright is installed

If ~/.claude has symlinks (like hooks/ or skills/), the wrapper resolves them and binds the targets so that they still work inside the container. If a symlink points at a location outside of ~/.claude, I recommend making it relative. If absolute, it will still be bound (and available in-container), but it will use and thus expose the real host path.

5) Make linked git worktrees behave correctly

For linked worktrees (where .git is a plain file), it rewrites the gitdir reference to an in-container path and binds the main worktree’s .git directory accordingly. This avoids broken git operations from inside the masked path setup.

Hooks: how the privacy layer works

The hook setup is simple: one router script, three concrete hooks, and a settings file that wires everything to Claude Code events.

For details on Claude Code hooks, see the official docs.

Hook wiring

hooks/hooks.settings.json attaches hooks to:

  • PreToolUse for Read
  • PreToolUse for Bash
  • PostToolUse for Edit|Write

All of those call the same entrypoint (hook-router), which dispatches based on hook_event_name + tool_name:

case "$EVENT" in
    PreToolUse)
        case "$TOOL" in
            Bash) HOOK="$HOOKS_DIR/pre-bash-tool" ;;
            Read) HOOK="$HOOKS_DIR/pre-read-tool" ;;
        esac
        ;;
    PostToolUse)
        case "$TOOL" in
            Edit|Write) HOOK="$HOOKS_DIR/post-edit-write-tool" ;;
        esac
        ;;
esac

This single-router pattern keeps settings.json clean and also let’s us enforce guardrails for all hooks in one place. If an unexpected event/tool pair shows up, hook-router exits with code 2 (which blocks the tool call) and gives the agent a clear error message instead of failing silently.

Read hook: line-aware scrubbing

Quite frustratingly, Claude Code has no hook for directly intercepting tool outputs before they reach the agent. To work around this, we instead use a PreToolUse hook (pre-read-tool) that checks the file content that the agent wants to read (using its built-in Read tool). The hook extracts file_path, offset, and limit from the Read tool’s JSON payload, reconstructs the exact line window with awk, runs the content through scrub, and compares the raw vs scrubbed output. If raw == scrubbed (i.e. no secrets in the content), the hook exits with code 0 and the agent sees the normal Read output. Otherwise, the hook returns permissionDecision: "deny" plus permissionDecisionReason containing the scrubbed content (i.e. it still gets to see the file content, but with secrets removed and wrapped in a “denied tool call” response). Misusing the permissionDecisionReason field this way is a fairly hacky solution, but it’ll have to do until Anthropic finally adds something like a ToolResultTransform hook.

Bash hook: wrap and pipe into scrub

The Bash hook (pre-bash-tool) is a Python script that rewrites the Bash command the agent wants to run so that its output is scrubbed before it reaches the model:

wrapped = original_command + " 2>&1 | scrub"

It returns updatedInput.command in hookSpecificOutput, so Claude runs the wrapped command. Separation between STDOUT/STDERR is lost in what the model sees, but I haven’t had any issues with this so far (and AFAIK this would happen with a regular tool call anyway).

The hook fails early if scrub is missing (sys.exit(2), which again blocks the tool call), so setup issues are visible right away and don’t just result in un-scrubbed output leaking to the agent.

Edit|Write hook: Ruff after Python edits

Note: This hook is not really required for our privacy goals. I was just too lazy to properly disentangle the different types of hooks I’m using so I can keep them in separate repos while still using the same router and testing setup etc.

The post-edit hook (post-edit-write-tool) only triggers on *.py targets.

Flow:

  1. Check whether ruff exists.
  2. Run ruff check --fix <file>.
  3. If issues remain, send additionalContext back to Claude with Ruff output and a prompt to fix them.

This gives lightweight auto-fixing plus immediate feedback when lint problems remain. If ruff is unavailable, the hook returns a stop reason (cannot find ruff) so that the agent can let you know that there is an issue with the setup.

The scrub binary

In order for the scrubber hooks to work an executable named scrub must be on the PATH (will be bound to /usr/bin/scrub inside the container).

clappt explicitly warns if scrub looks like plain text (file --mime-encoding not binary), as it might be read by the agent (something like cat $(which scrub) should be prevented by restrictive permissions, but it’s good to have defense in depth).

While a shc-compiled binary is not actually encrypted and can be reverse engineered, it does prevent accidental disclosure of scrubbing rules.

Hooks setup and testing

The helper script hooks/add-hooks-to-settings-and-symlink.sh does two things:

  • replaces the hooks field in ~/.claude/settings.json with the content of hooks.settings.json
  • symlinks the repo’s hooks dir into ~/.claude/hooks (with a relative symlink; this is required so that the hooks work properly inside the container)

To check that the hooks setup is working, run ./clappt --test-hooks which starts the container and automatically prompts Claude Code to perform an end-to-end sanity check with test files and a test scrubber.

The test scrubber replaces hello with hi; so behavior is easy to verify:

  • test-1.txt already contains hi
  • test-2.txt contains hello
  • after hooks, both should look the same to the agent

The prompt asks the agent to read both files twice and confirm if they contain the same content: once with the Read tool and once with the Bash tool and cat. Then, it is asked to edit the .py test file and to confirm whether ruff complained about the unused variable.

Final take

As said above, this setup is fairly specific to my dev setup:

  • micromamba-based Python environments
  • Rust

If your workflow is different (e.g. uv), the wrapper will need a few tweaks, but those should be easy to implement.

YMMV

clappt is intentionally modest in scope and definitely not a perfect sandbox. However, it does reduce the risk of privacy leaks and “oops” moments while keeping a decent developer experience. If you treat it as one element of a broader safety strategy, it is useful. If you run it with claude --dangerously-skip-permissions on your personal machine, things will probably still go fine for some time, but eventually you’ll get burnt.


Footnotes:

  1. and I think I’m going to switch to Pi with Kimi K2.5 soon as I’m just too stingy to pay 100$ or 200$ per month to get real work done. Once I make the switch, I’ll write about it here.

  2. Incredibly hacky, I know, but it works for masking the output of simple ls -l commands etc. and so far I haven’t noticed it breaking anything.

  3. I like to have a separate cli-utils environment and put it on the PATH in my ~/.bashrc (in addition to the active environment), which is convenient for installing the most recent versions of standard CLI tools etc.