Claude Code isolation for lazy people
Some low-hanging fruits in terms of security / privacy when running Claude Code. 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 ⚠️ 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 ‼️ Caveat️ ‼️ 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: The This makes executables in bound locations (for example, a conda env’s The hooks for sanitizing tool output before it reaches the LLM expect a Using The main job of the 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 This way tool calls do not casually leak the real host path. The script bind-mounts Claude’s config/state and some utilities. Additionally, it optionally mounts: If For linked worktrees (where 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. All of those call the same entrypoint ( This single-router pattern keeps 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 The It returns The hook fails early if 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 ( Flow: This gives lightweight auto-fixing plus immediate feedback when lint problems remain. If In order for the scrubber hooks to work an executable named While a The helper script To check that the hooks setup is working, run The test scrubber replaces The prompt asks the agent to read both files twice and confirm if they contain the same content: once with the As said above, this setup is fairly specific to my dev setup: If your workflow is different (e.g. 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. ↩ Incredibly hacky, I know, but it works for masking the output of simple I like to have a separate
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
clapptgit 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.uv) and Rust, but modifying the wrapper to support other setups should be straightforward.Why Apptainer?
bubblewrap and socat) that can only be installed with admin perms (which I don’t have on some of the machines I work on).
On modern Ubuntu (24.04+), truly unprivileged Apptainer/Singularity install is no longer possible due to AppArmor restrictions.The container image
git, bash, jq, etc.)poppler-utils, imagemagick)shc to compile shell scripts into non-transparent binaries%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"
fibin/ or ~/.cargo) available inside the container.What is
shc?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).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
apptainer run wrapper is mostly PATH and environment plumbing.1) Mask user and home context
/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}"3) Keep host state, but only what is needed
"cli-utils" if there is one 3~/.cargo, ~/.rustup) if available4) Handle symlinks
~/.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
.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
Hook wiring
hooks/hooks.settings.json attaches hooks to:PreToolUse for ReadPreToolUse for BashPostToolUse for Edit|Writehook-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
;;
esacsettings.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 scrubbingPreToolUse 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 scrubBash 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"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).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 editspost-edit-write-tool) only triggers on *.py targets.ruff exists.ruff check --fix <file>.additionalContext back to Claude with Ruff output and a prompt to fix them.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 binaryscrub 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).shc-compiled binary is not actually encrypted and can be reverse engineered, it does prevent accidental disclosure of scrubbing rules.Hooks setup and testing
hooks/add-hooks-to-settings-and-symlink.sh does two things:hooks field in ~/.claude/settings.json with the content of hooks.settings.json~/.claude/hooks (with a relative symlink; this is required so that the hooks work properly inside the container)./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.hello with hi; so behavior is easy to verify:test-1.txt already contains hitest-2.txt contains helloRead 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
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:
ls -l commands etc. and so far I haven’t noticed it breaking anything. ↩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. ↩