Claude Code: Open Permissions, Hard Limits

I recently started using Claude Code but I found the permission prompts both hard work and unreliable.
Trying to work out what I was agreeing to was hard work - and all too often the agent managed to do things I hadn’t wanted.
So I turned the inbuilt soft limits off (--dangerously-skip-permissions) and put Claude in a container I call a devbox. Inside the devbox Claude can do whatever it likes. The container decides what can actually be written, where it can connect to, and which credentials it holds, and Claude can’t change any of that from inside.
The idea
The Claude coding agent has what it calls a sandbox - a set of rules on what it can do and what it would eed permission for .. but they aren’t really rules .. more like guidelines and LLMs are about probablities - if an action seems like its probably OK it goes ahead.
It also makes using the agent really slow if you have to constantly read cryptic bash one-liners and work out if they are safe.
Hard limits work the other way round. I decide once what the agent may touch, the operating system enforces it, and then I stop worrying about individual commands.
The rule I keep to: every limit is enforced by something the agent can’t edit. A note in a prompt saying “don’t push to git” is a request. A read-only mount of .git is a limit.
The container
The devbox is a Docker image built on the same base as our production PHP images (FrankenPHP / PHP 8.5), with composer, node, Go, Python, the usual CLI tools, and Claude Code, glab, doctl, kubectl and helm added.
Composer and Node are used in the build pipeline - the rest are the tools I use for devops work.
A small bash script, devbox, starts it:
devbox # claude in the current directory
devbox --net myapp-net # ... on the app's docker compose network
devbox --net myapp-net ~/work/src/myorg # a whole tree of repos at once
devbox --shell # bash instead of claude
devbox --stop # throw the container away
There is one container per directory, and it gets reused on later runs. The directory is mounted at the same path as on the host, so paths in Claude’s output work when I paste them into my own terminal.
Claude itself starts like this:
docker exec -it "$name" claude --dangerously-skip-permissions --append-system-prompt "$container_prompt"
The appended prompt tells Claude what it’s running in: which tools there are, which networks are attached, that docker isn’t available, that the network is allowlisted and the credentials are read-only. None of that is a restriction in itself. It just saves Claude finding the walls by hitting them.
Limits on what can be written
Only the mounted project directory is writable. Inside it, devbox finds every git repo and mounts these read-only on top of it:
.git Dockerfile docker-compose*.yml .docker .devcontainer .idea .vscode
They are all things that run on the host:
.githolds hooks and config (core.fsmonitor, aliases) that my own git runs the next time I use it- Dockerfiles and compose files get built and run by me, outside the container
.ideaand.vscodecontain run configurations my IDE will run
If Claude could edit any of those, a change made in the container would run outside it. Read-only, they’re just files it can read.
A nice side effect: Claude can’t commit. It leaves changes uncommitted, I review the diff and I commit. When it wants to change a Dockerfile it writes a Dockerfile.proposed next to it for me to diff.
There’s no Docker socket in the container either. Access to the socket would mean root on the host, so I run docker compose up myself and the devbox joins the compose network to reach MySQL and Redis by name.
The repo that holds the devbox script, and its config, is mounted read-only if it ends up inside the mounted tree.
Limits on the network
Outbound traffic is denied by default. An iptables script runs at container start:
- the loopback interface, established connections and DNS are allowed
- the attached Docker networks’ subnets are allowed (so the database and Redis work)
- the Docker host itself (the gateway) is rejected, so nothing listening on my machine is exposed
- HTTPS (port 443) is allowed only to an ipset of addresses resolved from an allowlist
- everything else is rejected
The allowlist is a plain file of domains: the Anthropic API, packagist, npm, GitHub, GitLab, the Go module proxy, PyPI, the DigitalOcean API, the Kubernetes API hosts, a few staging sites.
The point is that Claude can’t widen the firewall:
- the allowlist files are bind-mounted read-only, from a config directory outside the project tree
- the container user can run exactly one command as root,
init-firewall.sh, via sudo. It reads only those files, never environment variables, so re-running it can’t add anything firewall-allow(adding a domain on the fly) is root-only, so I run it from the host withdocker exec -u root- the container needs
NET_ADMINfor the firewall, but thecmsuser can’t use it without sudo
When Claude hits a blocked host it fails within a millisecond. It tells me which domain it needed and I decide whether to add it.
CDN hosts were a problem at first, because they rotate their IP addresses and the allowlist was resolved only once, at start. Now a devbox-reload-firewall script on the host re-resolves the domains in running devboxes. It only ever adds addresses, and it swaps the rules with one iptables-restore, so the firewall is never open while it works.
GitHub publishes its IP ranges, so those get added directly.
Credentials: read-only by design, not by request
Each tool gets credentials that can’t write in the first place, instead of an admin login plus instructions to be careful.
The devbox mounts three directories from the host:
| Directory | Mounted as | Writable? | Holds |
|---|---|---|---|
devbox-config/ | allowlist, kubeconfig, env files | no | things that limit Claude |
devbox-creds/ | ~/.config | yes | glab / doctl logins |
devbox-claude/ | ~/.claude | yes | Claude’s login, settings, memory |
Keeping config and creds apart matters. Anything the container can write can’t be allowed to define its own limits.
DigitalOcean
doctl logs in with a custom-scoped API token that has read scopes only, and specifically not Kubernetes cluster credential access. So doctl can list DNS records and databases but can’t change them.
GitLab
glab uses a fine-grained personal access token: read API and repository, plus write for issues.
That’s a deliberate exception. I like Claude to raise and update issues, and losing an issue is cheap. It’s also the one place where “the agent should ask first” is a working rule rather than an enforced limit.
Kubernetes
Each cluster gets its own ServiceAccount:
apiVersion: v1
kind: ServiceAccount
metadata:
name: readonly
namespace: kube-system
---
# The built-in "view" role covers pods, pods/log, events, deployments, services etc. (no secrets)
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: readonly-view
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: view
subjects:
- kind: ServiceAccount
name: readonly
namespace: kube-system
---
# "view" doesn't include cluster-scoped things like nodes, or metrics for `kubectl top`
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: readonly-extras
rules:
- apiGroups: [ "" ]
resources: [ "nodes", "namespaces", "persistentvolumes", "events" ]
verbs: [ "get", "list", "watch" ]
- apiGroups: [ "metrics.k8s.io" ]
resources: [ "pods", "nodes" ]
verbs: [ "get", "list" ]
- apiGroups: [ "storage.k8s.io" ]
resources: [ "storageclasses" ]
verbs: [ "get", "list", "watch" ]
# ... plus a ClusterRoleBinding for readonly-extras and a long-lived
# kubernetes.io/service-account-token Secret for the kubeconfig
The built-in view role is a good fit. It covers pods, logs, events and deployments across namespaces, and leaves out Secrets, exec and port-forward. Exec in particular is write access in all but name.
A setup.sh script runs on the host, with my admin context (admin credentials never go near a devbox). It:
- applies the YAML to staging and production
- writes a kubeconfig with
readonly-stagingandreadonly-productioncontexts - makes staging the default context, so a command without
--contextnever hits production by accident - adds the cluster API hosts to the devbox allowlist
- runs a check script that confirms both contexts can read and can’t write
The kubeconfig is mounted read-only into the devbox. There’s also a revoke.sh: delete the token Secret and that kubeconfig is dead.
One trap I found: kubectl auth can-i get services/proxy says yes when it can’t. Test with --subresource=proxy, or just send a real request.
Prometheus
Prometheus doesn’t need anything extra. The API server’s service proxy turns the HTTP method into the RBAC verb, so a GET-only grant on one Service gives Claude queries and nothing else:
kubectl --context readonly-production get --raw \
'/api/v1/namespaces/<prometheus-ns>/services/<prometheus-svc>:9090/proxy/api/v1/query?query=up'
Loki
I tried the same trick with Loki and decided against it.
GET-only on Loki’s Service isn’t read-only, because some Loki routes do things on a GET: /flush flushes the ingesters, /config dumps the running config and /debug/* runs profilers. Our Loki also has no memory limits and keeps its data on pod-local storage, so a heavy enough query could get the pod evicted and lose all the logs. “GET only” was the wrong boundary.
So Loki gets a small proxy, written in Go using only the standard library, running in a container on the host. It sits on the compose network, and devboxes reach it at http://loki-production:3100 and http://loki-staging:3100. One container per cluster, named after it, so a query can’t go to the wrong cluster by default.
devbox ──http──> loki-production:3100 ──https + token──> API server ──service proxy──> Loki
(no credentials) (path allowlist, limits) (RBAC: GET, one Service)
The proxy only lets through:
| Check | Rule |
|---|---|
| Method | GET |
| Paths | the query endpoints: query, query_range, labels, label/<name>/values, series, index/*. Exact match, and encoded paths like %2F are refused |
| Time range | at most 24h per query, including [range] selectors inside the LogQL |
limit | at most 5000 |
| Concurrency | 2 at once, the rest get a 429 straight away |
| Timeout | 2 minutes |
Everything else gets a 4xx from the proxy and never reaches the cluster. Each request is logged with its query.
The proxy’s token lives in my home directory on the host, outside the workspace the devboxes mount. The setup script refuses to write it anywhere inside the workspace. The container runs with a read-only filesystem, all capabilities dropped and no-new-privileges.
There are two layers here: the cluster refuses anything but GET, and the proxy refuses anything but the GETs that are safe.
Telling Claude the limits are deliberate
One behaviour surprised me. When a limit blocked it, the agent kept trying to workaround it or would suggest giving itself more RBAC.
So my global CLAUDE.md says plainly:
Restrictions are deliberate. When a firewall, a read-only mount, read-only cluster access or a permission denial blocks something, stop and say so. Hand over the command for the user to run; never route around it.
Never propose giving Claude more access (RBAC, proxies, credentials) as a workaround.
So now if something is blocked from the agent it can give me commands to run on my host with my credentials and I’ll pass back output. If I decide this is tedious I might explore what access the agent needs and if I can be doen safely.
What this doesn’t protect against
- Read access is still access. Claude can read pod logs, config and production log lines, and those could leave through any allowlisted host. The firewall makes that harder, it doesn’t make it impossible.
- Staging sites are reachable, and anything you can do to staging with a browser, the agent can do with curl.
- GitLab issues are writable, by choice.
- The project directory is writable, so a bad edit can still break the code. That’s what review and git are for, and git history is out of its reach.
.envfiles. These aren’t committed, and Claude could mess them up - I also need to be careful to avoid keeping sensitive secrets in them.
The threat I’m guarding against is mainly an agent that makes a confident mistake or follows an instruction it read in a web page or a dependency, not a determined attacker in the container. For that, hard limits on writes and credentials, plus a default-deny network, have let me stop clicking “yes” without worrying about what I just agreed to.
What it gives me
Being able to ask the agent to look at code, production logs, metrics, and Sentry errors all at once has been very powerful - it becomes much quicker to drill into production issues. Recently we have been seeing outages caused by badly behaved scrapers and I’ve been able to make some really targeted performance and resilience changes because the agent drilled into logs and could report effectively on what was causing the problem, help me identify bottlenecks, tweak code, and optimise helm charts.
I would never trust an agent with write access to all of this.
Where I need to make production changes - Claude writes code, I review the code, then run it.
This is much much easier than reviewing individual agent commands.