If you’re using Claude Code for real development work – not just quick one-off tasks, you’ll eventually hit the same tension I did: you want Claude to move fast without asking permission for every git status, but you don’t want to hand it unrestricted access to your entire filesystem. This post walks through how I configured permissions and sandboxing for a Kotlin Multiplatform project (Android + iOS), the mistakes I made along the way, and the final working config.
The starting point: allow lists aren’t a security boundary
Claude Code’s settings.json supports permissions.allow and permissions.deny rules, scoped by tool. For Bash, that looks like:
"allow": ["Bash(git *)", "Bash(cp *)", "Bash(mv *)"]
This is convenient. It stops Claude from prompting you on every git status or git diff. But it’s important to understand what these rules actually check: they match the literal command text, not what the command touches. That means Bash(git *) matches git status just as happily as git -C /some/other/project mv file1 file2. The rule doesn’t know or care that the second command reaches a completely different directory. Same story for cp * and mv * – nothing about the pattern restricts the destination path.
Takeaway: permission rules are a policy layer (they express what you’d approve if asked), not a fence. If your goal is “Claude can never touch anything outside this project,” allow/deny rules alone don’t get you there.
Splitting the concern: Edit/Write vs. Bash
One nuance that isn’t obvious at first: Claude Code’s own file-editing tools (Edit, Write) are scoped to the working directory by default. A rule like Edit(./**) genuinely confines Claude’s native file edits to the project tree.
The Bash tool is different, it inherits your full shell-level filesystem permissions, and nothing about the permission system restricts where a Bash command can act, only whether the command text matches an allow rule. This is the gap that actually matters if you’re worried about scope creep into other projects, your home directory, or system files.
Closing the gap: sandboxing
The real fence is Claude Code’s sandbox feature, an OS-level enforcement (via bubblewrap on Linux, via Seatbelt on MacOS and nothing on Windows) that restricts what the Bash tool and its child processes can actually reach on disk and over the network, independent of what the permission rules say.
"sandbox": {
"enabled": true,
"autoAllowBashIfSandboxed": true,
"allowUnsandboxedCommands": false,
"network": {
"allowedDomains": ["plugins.gradle.org"]
}
}
A few settings worth calling out:
allowUnsandboxedCommands: false– by default, if a sandboxed command fails due to a restriction, Claude Code can retry it outside the sandbox with your approval. That’s an escape hatch that defeats the purpose if you’re relying on the sandbox as your boundary. I turned it off.autoAllowBashIfSandboxed: true– once the sandbox is the real boundary, commands that stay inside it don’t need individual prompts. This is what makes the workflow fast again.network.allowedDomains– Bash-level network access is denied by default under the sandbox; you allow-list only what you need (Maven Central, the Gradle plugin portal, etc.).
Gotcha #1: the sandbox needs a dependency you might not have
I enabled sandbox.enabled: true and kept getting prompted for commands like mkdir that should’ve been silently allowed. Turned out the sandbox wasn’t actually active – on Ubuntu, Claude Code’s Linux sandboxing depends on bubblewrap (bwrap), which wasn’t installed. With sandbox.enabled: true but no bwrap, the setting silently no-ops and every command falls back to plain permission-rule evaluation – which explains why explicit Bash(...) allow rules still matter even when you think you’re sandboxed.
Check before you trust it:
which bwrap
If that comes back empty, install it, otherwise your “sandbox” isn’t doing anything.
Gotcha #2: compound commands need every piece covered
Claude Code splits chained commands on &&, ||, ;, |, and newlines, and checks each subcommand independently. So a single logical action like “make a directory, then move a file into it”:
mkdir newdir && git mv oldfile newdir/oldfile
only runs cleanly if both mkdir * and git * have allow rules. We had git * but not mkdir *, so the whole chain prompted, which looked like a git mv problem but was actually the unmatched mkdir.
Fix: allow-list the small utility commands you don’t think about (mkdir, ls, echo, whoami, id) alongside the “main” commands, since Claude often chains them for verification and diagnostics.
Gotcha #3: ~ doesn’t expand in permission-rule paths
I wanted to grant write access to the Gradle cache at ~/.gradle and initially wrote:
"Edit(//~/.gradle/**)",
"Write(//~/.gradle/**)"
This silently didn’t work. Edit(...)/Write(...) permission-rule paths are plain strings evaluated by Claude Code, not passed through a shell, there’s no shell to expand ~ into your home directory. It was being treated as a literal path segment, not your home dir.
The symptom was confusing: Gradle builds failed partway through, and Claude went digging with whoami / id / ls ~/.gradle to diagnose what looked like a permissions problem but was actually a sandbox boundary silently blocking cache writes.
Fix: use the actual sandbox.filesystem.allowWrite key:
"sandbox": {
"filesystem": {
"allowWrite": ["~/.gradle"],
"denyRead": ["~/.aws/credentials"],
"allowRead": []
}
}
filesystem.allowWrite– additional paths sandboxed Bash commands can write to. Arrays merge across user/project/managed settings scopes, and are also merged withEdit(...)allow rules – so you don’t need to duplicate the same path in both places.filesystem.denyWrite/denyRead– explicit blocks, merged the same way withEdit/Readdeny rules.filesystem.allowRead– carves out exceptions within adenyReadregion.
Gotcha #4: Gradle can’t talk through the sandbox’s network proxy
Even with the right filesystem.allowWrite entry and a seemingly correct network.allowedDomains list (plugins.gradle.org, repo.maven.apache.org, dl.google.com, etc.), Gradle builds still failed with UnknownHostException, and the domains were visibly present in the resolved sandbox config. Not a typo, not a stale setting. The allowlist was loaded correctly and still didn’t apply.
This turned out to be a deeper mismatch between how the sandbox enforces network rules and how the JVM does networking, and it shows up on both Linux/WSL2 and macOS, just via different mechanisms:
- On Linux/WSL2, the sandbox’s network allowlist is enforced by intercepting DNS resolution at the system-call level – specifically the path used by the C library resolver (the one
curland most native tools use). Java’s networking stack doesn’t reliably go through that same path, so its DNS lookups slip past the interception point entirely. The domain being “in the list” is irrelevant if the request never gets checked against the list in the first place. - On macOS, Seatbelt’s kernel rule only permits outbound connections to
localhost:*– everything else is denied outright. The only way to reach the real internet is through the local proxy, which sits on localhost and is therefore allowed. But that only works if the client actually routes through the proxy, which usually happens via theHTTP_PROXY/HTTPS_PROXYenvironment variables Claude Code sets for sandboxed processes. The JVM doesn’t automatically honor those – it needs its own-Dhttp.proxyHost/-Dhttps.proxyHostflags, or-Djava.net.useSystemProxies=true. Without that, Gradle’s JVM tries a direct connection, and Seatbelt just denies it at the socket layer, since only localhost is permitted.
Different failure mechanics, same underlying category of problem: a JVM-based tool doesn’t route through whatever path the sandbox uses to enforce its network allowlist, so the allowlist configuration is correct and simply never gets consulted.
The fix: exclude Gradle from the sandbox rather than fight its networking stack:
"sandbox": {
"excludedCommands": ["gradle *", "./gradlew *", "gradlew *"]
}
This makes Gradle invocations run with normal host networking (bypassing the proxy/DNS-interception layer that isn’t working for it anyway), while everything else, like git, cp, mv, curl, etc. stays fully sandboxed. The tradeoff is explicit and scoped: Gradle gets full network + filesystem access for the duration of its own invocation, nothing else does. This is the same pattern commonly used for Docker, which has an equivalent problem reaching its socket from inside a sandbox.
(An alternative is manually pointing the JVM at the sandbox’s proxy via GRADLE_OPTS with the right -Dhttp(s).proxyHost/Port flags, this keeps Gradle inside the sandbox’s network boundary, so your allowedDomains list genuinely still applies to it. I didn’t go this route: it’s more fragile, since it depends on the sandbox’s proxy port staying stable and every Gradle daemon process correctly inheriting the JVM flags. excludedCommands is simpler and portable across both platforms with identical config.)
If your team hits a similar “allowlisted domain still fails to resolve” symptom with a different JVM-based tool (Maven, sbt, a Kotlin/Native toolchain, etc.), suspect this same class of issue before assuming the config is wrong, check whether the domain is genuinely reachable if you temporarily add the tool to excludedCommands as a diagnostic step.
The final config
{
"theme": "auto",
"permissions": {
"allow": [
"WebFetch",
"WebSearch",
"Bash(git *)",
"Bash(cp *)",
"Bash(mv *)",
"Bash(mkdir *)",
"Bash(gradle *)",
"Bash(./gradle *)",
"Bash(gradlew *)",
"Bash(./gradlew *)",
"Edit(./**)",
"Write(./**)"
],
"deny": [
"Bash(git commit*)",
"Bash(git push*)",
"Bash(git clone*)",
"Bash(git remote add*)",
"Bash(git remote remove*)",
"Bash(git remote set-url*)",
"Bash(git submodule*)"
]
},
"sandbox": {
"enabled": true,
"autoAllowBashIfSandboxed": true,
"allowUnsandboxedCommands": false,
"filesystem": {
"allowWrite": ["~/.gradle"]
},
"network": {
"allowedDomains": ["plugins.gradle.org", "..."]
}
}
}
Note what’s deliberately absent: git commit and git push stay denied even though everything else in git is wide open. This was an intentional choice, local commits and remote pushes are the two actions that are hard to “undo” cleanly or that leave a project’s history looking like something happened when the author wants a chance to review first. Staging changes with git add and reviewing with git diff --staged gives a natural checkpoint before that final, deliberate git commit happens by hand.
Takeaways for the team
- Permission rules (
allow/deny) are policy, not a fence. They stop Claude from deciding to do something; they don’t stop a command from reaching somewhere, because Bash rules match text, not paths. - The sandbox is the actual fence, but only if its OS-level dependency (
bubblewrapon Linux) is installed. Check withwhich bwrapbefore assuming it’s active. - Compound commands need every subcommand covered. If Claude chains
mkdir && git mv, both need allow rules, or the whole chain prompts. ~doesn’t expand inEdit(...)/Write(...)permission-rule paths – use full absolute paths there, or better, usesandbox.filesystem.allowWrite, which does support~.- Deny the small number of actions that matter most (commit, push, clone, remote mutation) rather than trying to allow-list your way to safety – a short, deliberate deny list is easier to reason about and audit than a long allow list.
- A correctly configured
allowedDomainscan still silently fail for JVM-based tools. Gradle (and likely Maven, sbt, and similar) doesn’t reliably route through the sandbox’s network enforcement path on either Linux or macOS, so the fix isexcludedCommands, not more domains in the allowlist. - Native Windows has no sandbox at all, only WSL2 does (WSL1 doesn’t work either). On native Windows,
denyrules only stop Claude’s built-in Read/Edit/Write tools; Bash commands bypass them entirely, since there’s no bubblewrap/Seatbelt equivalent enforcing anything at the OS level. Anyone on the team using native Windows should run Claude Code inside WSL2 to get the same boundary the rest of this config assumes.