Running Claude Code Safely in Dangerous Mode

Claude Code's --dangerously-skip-permissions flag is incredibly useful for automation. It removes all confirmation prompts — file writes, shell commands, everything runs without asking. But the name is honest: a misguided prompt could rm -rf your home directory or push to the wrong branch.

I wanted the productivity of full automation without the risk of nuking my machine. The solution I landed on has two parts: a Lima VM for isolation, and a wrapper repo pattern for keeping my Claude configs version-controlled even when my team doesn't use Claude.

Note: The Lima setup in this post is specific to M series Macs. The vz backend and Rosetta for Linux VMs are Apple-specific — they won't work on Intel Macs or Linux hosts.

Here's the overall picture:

┌────────────────────────────────────────────────────────────────┐
│  macOS Host (ARM64 / Apple Silicon)                            │
│                                                                │
│  ┌──────────────────────────────────────────────────────────┐  │
│  │  Lima VM (Ubuntu 24.04 / Apple vz) — ARM64               │  │
│  │                                                          │  │
│  │  ┌────────────────────────────────────────────────────┐  │  │
│  │  │  Claude Code                                       │  │  │
│  │  │  (--dangerously-skip-permissions)                  │  │  │
│  │  │                                                    │  │  │
│  │  │  ┌──────────────┐  ┌────────────────────────────┐  │  │  │
│  │  │  │ Wrapper Repo │  │ Rootless Docker            │  │  │  │
│  │  │  │              │  │                            │  │  │  │
│  │  │  │ CLAUDE.md    │  │  ┌──────────────────────┐  │  │  │  │
│  │  │  │ .claude/     │  │  │  Containers          │  │  │  │  │
│  │  │  │ .mcp.json    │  │  │  ARM64 or x86_64     │  │  │  │  │
│  │  │  │              │  │  │  (via Rosetta)       │  │  │  │  │
│  │  │  │ repo/        │  │  └──────────────────────┘  │  │  │  │
│  │  │  │ ├── svc-a/   │  │                            │  │  │  │
│  │  │  │ └── svc-b/   │  └────────────────────────────┘  │  │  │
│  │  │  │              │                                  │  │  │
│  │  │  │ shared/      │                                  │  │  │
│  │  │  │ issues/      │                                  │  │  │
│  │  │  └──────────────┘                                  │  │  │
│  │  └────────────────────────────────────────────────────┘  │  │
│  │                                                          │  │
│  │  mounts: []  ← no access to host filesystem              │  │
│  └──────────────────────────────────────────────────────────┘  │
│                                                                │
│  SSH keys, credentials, other projects — all safe              │
└────────────────────────────────────────────────────────────────┘

Part 1: Isolating Claude in a Lima VM

Why Lima?

Lima gives you a lightweight Linux VM on macOS. Claude runs inside the VM with full permissions, but the blast radius is contained. If something goes wrong, you delete the VM and start fresh. Your host machine is untouched.

You might wonder why not just use Docker. The thing is, Claude itself often needs Docker — building images, running containers, docker compose. Running Docker-in-Docker adds complexity. With Lima, Docker runs natively inside the VM, and Claude can use it directly without socket mounting or privilege escalation tricks.

The Key Config Decisions

I wrote a Lima YAML config that provisions a complete Claude Code development environment. You can grab the full YAML here, but I'll walk through the important parts.

No Host Mounts

mounts: []

By default, Lima mounts your macOS home directory as read-only inside the VM. We disable this entirely. The whole point is isolation — if Claude can read your home directory, it can see your SSH keys, credentials, and other projects. An empty mount list means the VM is completely self-contained.

It is possible to mount your code from the host so you see the same files on both sides, and there are scenarios where that's useful — for example, a shared folder to drop images or reference files into the VM easily. But the file access mechanism — virtiofs — is slow. Something like npm install writing thousands of files to node_modules will take noticeably longer over a mount. If your workflow involves heavy filesystem operations, keeping mounts empty and cloning repos directly inside the VM is the better experience.

That said, if you connect to the VM via VS Code's Remote SSH, you can browse and edit files inside the VM with a familiar interface, and drag and drop files in when needed. There's some network latency for large files, but for day-to-day coding it works well enough.

Apple Virtualization Framework

vmType: 'vz'
vmOpts:
  vz:
    rosetta:
      enabled: true
      binfmt: true

We use Apple's native vz backend, which is the default on Apple Silicon (it's what Docker Desktop uses too). It has lower overhead than QEMU since it's integrated directly into macOS. The Rosetta config is the key advantage here — it registers as a binfmt handler inside the Linux VM, so any x86_64 binary (including linux/amd64 Docker containers) runs automatically at near-native speed. Many Docker images are still published as amd64-only, so this saves a lot of pain.

A note on Rosetta, because I was initially confused by this. Apple is phasing out Rosetta 2 on macOS — the translation layer that lets Intel Mac apps run on Apple Silicon. That's expected to be fully gone by macOS 28. But the Rosetta used here is a different thing: it's Rosetta for Linux VMs, part of Apple's Virtualization framework, which translates x86_64 Linux binaries inside ARM64 VMs. Whether this Linux VM Rosetta survives long-term is still an open question — Apple hasn't explicitly said. But for now it works, and it's what Docker Desktop uses under the hood for the same purpose.

Rootless Docker

The provisioning scripts install Docker but immediately disable the system daemon, then set up rootless Docker instead:

- mode: system
  script: |
    #!/bin/bash
    set -eux -o pipefail
    command -v docker >/dev/null 2>&1 && exit 0
    export DEBIAN_FRONTEND=noninteractive
    curl -fsSL https://get.docker.com | sh
    systemctl disable --now docker
    apt-get install -y uidmap dbus-user-session

- mode: user
  script: |
    #!/bin/bash
    set -eux -o pipefail
    systemctl --user start dbus
    dockerd-rootless-setuptool.sh install
    docker context use rootless

This rootless Docker setup is copied from Lima's own Docker template. Even inside the VM, there's no reason to run Docker as root. When Claude executes a docker run command, the container processes run under the Lima user's UID, not root.

Claude Code Installation

- mode: user
  script: |
    #!/bin/bash
    set -eux -o pipefail
    command -v claude >/dev/null 2>&1 && exit 0
    curl -fsSL https://claude.ai/install.sh | bash

The native installer handles auto-updates, so you don't need to re-provision to get the latest version.

Playwright MCP

I use the Playwright MCP server inside the Lima VM so Claude can interact with web pages. One thing to note: you need to use Chromium, not Chrome. Playwright's Chrome stable build doesn't support Linux ARM64 — the install script explicitly errors out on aarch64. Chromium works fine though, and Playwright falls back to it automatically on ARM64 Linux.

Health Probes

The config includes probes that wait for Docker's rootless daemon and the Claude binary to be ready before Lima reports the VM as "started." Without these, limactl start would return immediately while things are still installing.

Day-to-Day Usage

# Create and start
brew install lima
limactl create --name=claude-dev claude-dev.yaml
limactl start claude-dev

# Enter the VM
limactl shell claude-dev

# Run Claude and log in (if using a Max subscription)
alias ccd="claude --dangerously-skip-permissions"
ccd
# then use /login inside the session

# When things go sideways
limactl delete claude-dev  # start fresh

Since the VM is isolated from your host, you'll need to set up SSH keys inside the VM independently and add them to GitHub (or wherever your repos live). The VM doesn't inherit your host's SSH config or keys — that's by design.

The nice thing about this setup is that it's disposable. If Claude makes a mess, you delete the VM and recreate it in a few minutes. No damage to your host.

Part 2: Version-Controlling Claude Configs

Now for the second problem. I use Claude Code across multiple repositories at work, but my team doesn't. I needed a way to keep my Claude configuration — CLAUDE.md context files, custom skills, MCP server configs — version-controlled and organized, without polluting the team's repos with files they don't use.

Why Not Git Submodules?

Git submodules were the obvious answer for nesting repos. But submodules create a diff in the parent repo every time the submodule's commit pointer changes. Since these repos update independently and frequently, I'd constantly have uncommitted submodule pointer changes cluttering my git status. I wanted the repos inside my workspace directory, but completely decoupled from the wrapper's git history.

The Wrapper Repo Pattern

The idea is simple: create a wrapper git repo that contains your Claude configs at the root, and nest the actual code repositories inside it. The inner repos are gitignored — not submoduled — so they maintain their own independent git histories without creating noise in the wrapper.

my-project/                     # Wrapper repo (its own git repo)
├── .gitignore                  # Ignores repo/ entirely
├── .claude/                    # AI assistant config (version controlled)
│   └── skills/
├── .mcp.json                   # MCP server configurations
├── CLAUDE.md                   # Cross-repo AI context
├── my-project.code-workspace   # VS Code workspace file
│
├── repo/                       # Gitignored - not tracked by wrapper
│   ├── service-core/           # Standalone git repo (own remote)
│   └── service-integrations/   # Standalone git repo (own remote)
│
├── shared/                     # Drop zone for reference files
│
└── issues/                     # Cross-repo issue tracking
    └── 316-feature-name/
        └── README.md

The .gitignore is dead simple:

repo/

That single line is what makes this work. The wrapper repo tracks your Claude configuration. The actual code repositories manage themselves.

Why This Matters for Claude

Claude Code operates within a directory scope — it reads and modifies files within its working directory and its children. By nesting repos inside the wrapper:

  • Claude sees everything. All codebases, documentation, and notes are within scope. It can cross-reference one service's API client with another service's route definitions.
  • Context files live at the root. CLAUDE.md provides project-wide context — how to build, test, and navigate the codebase. One file covers all repos because it sits above them.
  • Your team's repos stay clean. No CLAUDE.md files, no .claude/ directories, no MCP configs in repos where the rest of the team doesn't use Claude. Your AI setup is entirely in the wrapper.

The Supporting Folders

shared/ is an informal drop zone. When I need Claude to reference something from an external system — a Confluence export, a Slack thread screenshot, an API spec — I drop it here. No structure enforced, files come and go.

issues/ maps to GitHub issue numbers. Each folder has a README with research findings, implementation plans, and architecture decisions. When I start a new Claude session and say "work on issue 316," it reads the folder and picks up context without me re-explaining everything.

The VS Code Workspace File

{
  "folders": [{ "path": "." }],
  "settings": {
    "git.scanRepositories": ["repo/service-core", "repo/service-integrations"]
  }
}

The git.scanRepositories setting tells VS Code to detect the inner repos as separate git repositories, so you get proper source control for each one in the sidebar.

Setup

mkdir my-project && cd my-project
git init

mkdir repo
git clone git@github.com:org/service-core.git repo/service-core
git clone git@github.com:org/service-integrations.git repo/service-integrations

echo "repo/" >> .gitignore
mkdir -p shared issues .claude/skills

Adding a new repo is just a git clone into repo/. No submodule commands, no pointer commits.

Trade-offs

This isn't free. You lose the ability to pin repos at exact commits like submodules provide. New developers need to clone multiple repos (a setup script helps). And the wrapper repo doesn't guarantee which branches the inner repos are on.

But if you're working across a handful of related repos, you use AI coding tools, and you want your AI config version-controlled without affecting your team's workflow — this pattern works well.

Things to Note

The Lima VM isolates the filesystem — Claude can't touch your host machine's files, credentials, or other projects. That's the main win. But isolation has limits.

Once you set up SSH keys inside the VM and add them to GitHub, Claude has push access to your repositories. A bad or poisoned prompt could still force-push to a branch, open a bogus PR, or push sensitive data to a public repo. The VM protects your local machine, but it doesn't protect your remote services from actions Claude takes through authenticated tools like git and SSH.

Network access is also unrestricted — Claude can make HTTP requests, install packages, and reach any service the VM can reach. If you're running this on a corporate network, the VM has the same network access as your host.

In short: the VM is a blast radius reduction, not a full sandbox. It's a significant improvement over running --dangerously-skip-permissions directly on your Mac, but it's not airtight. Be thoughtful about what credentials and access you give the VM, and keep an eye on what Claude is doing with them.

Wrapping Up

These two patterns solve different but related problems. The Lima VM gives you a safe sandbox to let Claude run wild without risking your host machine. The wrapper repo gives you a place to keep your Claude setup organized and version-controlled, even when you're the only one on the team using it.

Neither approach is particularly clever. The VM is just containment, and the wrapper repo is just putting files where they need to be. But sometimes the simple solutions are the ones that actually stick.