Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 61 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ cd your-project
claude-vm
```

Clones the template into a fresh VM, mounts your current directory, and runs `claude --dangerously-skip-permissions`. The VM is deleted when Claude exits.
Clones the template into a fresh VM, mounts your current directory, and runs `claude --dangerously-skip-permissions` with `IS_SANDBOX=1` to suppress the dangerous mode confirmation prompt (the VM itself is the sandbox). The VM is deleted when Claude exits.

Any arguments passed to `claude-vm` are forwarded to the `claude` command:

Expand Down Expand Up @@ -95,6 +95,66 @@ npm install
docker compose up -d
```

## GitHub MCP Proxy

The GitHub MCP proxy gives the VM access to a single GitHub repository via the [GitHub MCP Server](https://github.com/github/github-mcp-server), without exposing any credentials to the VM.

When you run `claude-vm` inside a git repo with a GitHub remote, it automatically:

1. Detects the repository from `git remote`
2. Obtains a repo-scoped GitHub token via the device flow (browser-based OAuth)
3. Starts the proxy on the host, listening on a local port
4. Configures the VM to connect to the proxy over Lima's host networking

The proxy injects the GitHub token into upstream requests and enforces repo scope, so the VM can only access the current repository.

### Token generation and scoping

Tokens are generated via a [GitHub App](https://docs.github.com/en/apps) using the [device flow](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps#device-flow):

1. **One-time setup**: Create a GitHub App with `contents: write` permission and install it on your org/account. The App's Client ID is configured in `claude-vm.sh`.
2. **Per-session**: `github_app_token_demo.py` initiates the device flow — you approve in a browser, and a user access token is returned.
3. **Repo scoping**: The `--repo` flag resolves the repository's numeric ID and passes it as `repository_id` during the OAuth token exchange. GitHub scopes the resulting token to that single repository at the API level.
4. **Caching**: Tokens are cached in `~/.cache/claude-vm/` and automatically refreshed when expired, so you only need to re-authorize when the refresh token expires.

### Defense-in-depth

Even though the token is already scoped to one repository by GitHub, the proxy adds multiple enforcement layers:

| Layer | Mechanism |
|-------|-----------|
| **Owner/repo check** | Tool arguments with `owner`/`repo` must match the configured repo. Missing values are auto-injected. |
| **Search query scoping** | `repo:OWNER/REPO` is injected into search queries. `org:` and `user:` qualifiers are rejected. |
| **Tool allowlist** | Unknown tools are blocked by default (default-deny). Non-repo-scoped tools (`search_users`, `get_teams`, etc.) are blocked. |
| **Server-side filtering** | `X-MCP-Toolsets` header limits GitHub's server to `repos,issues,pull_requests,git,labels` by default. |
| **Lockdown mode** | `X-MCP-Lockdown` is enabled by default, hiding issue details from users without push access. |
| **Header protection** | VM cannot override `X-MCP-*` headers — the proxy strips them before injecting host-configured values. |

### Standalone usage

The proxy can also be run independently of `claude-vm`:

```bash
GITHUB_MCP_TOKEN=ghu_... \
GITHUB_MCP_OWNER=myorg \
GITHUB_MCP_REPO=myrepo \
python3 github-mcp-proxy.py
# Prints the listening port to stdout
```

### Configuration

| Env var | Description | Default |
|---------|-------------|---------|
| `GITHUB_MCP_TOKEN` | GitHub token (required) | — |
| `GITHUB_MCP_OWNER` | Repository owner (required) | — |
| `GITHUB_MCP_REPO` | Repository name (required) | — |
| `GITHUB_MCP_TOOLSETS` | Comma-separated [toolsets](https://github.com/github/github-mcp-server/blob/main/docs/remote-server.md) | `repos,issues,pull_requests,git,labels` |
| `GITHUB_MCP_TOOLS` | Comma-separated tool names (fine-grained) | *(all in allowed toolsets)* |
| `GITHUB_MCP_READONLY` | Set to `1` for read-only mode | `0` |
| `GITHUB_MCP_LOCKDOWN` | Set to `0` to disable lockdown | `1` |
| `GITHUB_MCP_PROXY_DEBUG` | Set to `1` for verbose logging | `0` |

## How it works

1. **`claude-vm-setup`** creates a Debian 13 VM with Lima, installs dev tools + Chrome + Claude Code, and stops it as a reusable template
Expand Down
177 changes: 176 additions & 1 deletion claude-vm.sh
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ claude-vm-setup() {
limactl shell "$CLAUDE_VM_TEMPLATE" bash -c "curl -fsSL https://claude.ai/install.sh | bash"
limactl shell "$CLAUDE_VM_TEMPLATE" bash -c 'echo "export PATH=\$HOME/.local/bin:\$HOME/.claude/local/bin:\$PATH" >> ~/.bashrc'


if ! $minimal; then
# Configure Chrome DevTools MCP server for Claude
echo "Configuring Chrome MCP server..."
Expand Down Expand Up @@ -219,6 +220,131 @@ _claude_vm_write_dummy_credentials() {
CREDS'
}

_claude_vm_start_github_mcp() {
local host_dir="$1"
local script_dir
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"

# Detect repo from git remote
local repo_url
repo_url=$(git -C "$host_dir" remote get-url origin 2>/dev/null)
if [ -z "$repo_url" ]; then
echo "Warning: No git remote found, skipping GitHub MCP" >&2
return 1
fi

# Parse owner/repo from remote URL
local owner repo
read -r owner repo < <(python3 -c "
import re, sys
url = sys.argv[1]
for pat in [r'git@github\.com:([^/]+)/([^/]+?)(?:\.git)?$',
r'https?://github\.com/([^/]+)/([^/]+?)(?:\.git)?(?:/.*)?$']:
m = re.match(pat, url)
if m:
print(m.group(1), m.group(2))
sys.exit(0)
sys.exit(1)
" "$repo_url")
if [ -z "$owner" ] || [ -z "$repo" ]; then
echo "Warning: Cannot parse GitHub remote '$repo_url', skipping GitHub MCP" >&2
return 1
fi

# Get scoped user token via device flow
echo "Requesting GitHub token for $owner/$repo..."
local token
token=$(python3 "$script_dir/github_app_token_demo.py" \
user-token --client-id Iv23liisR1WdpJmDUPLT \
--repo "$repo_url" --token-only \
--cache-dir "$HOME/.cache/claude-vm")
if [ -z "$token" ]; then
echo "Warning: Failed to get GitHub token, skipping GitHub MCP" >&2
return 1
fi

# Start GitHub MCP proxy (injects token, enforces repo scope)
echo "Starting GitHub MCP proxy..."
exec 4< <(GITHUB_MCP_TOKEN="$token" GITHUB_MCP_OWNER="$owner" GITHUB_MCP_REPO="$repo" \
GITHUB_MCP_PROXY_DEBUG="${GITHUB_MCP_PROXY_DEBUG:-0}" \
python3 "$script_dir/github-mcp-proxy.py")
_claude_vm_github_mcp_pid=$!
if ! read -r -t 5 _claude_vm_github_mcp_port <&4; then
echo "Warning: GitHub MCP proxy failed to start" >&2
kill "$_claude_vm_github_mcp_pid" 2>/dev/null
exec 4<&-
return 1
fi
exec 4<&-
echo "GitHub MCP proxy on port $_claude_vm_github_mcp_port (scope: $owner/$repo)"

# Start Git HTTP proxy (injects token, enforces repo scope)
echo "Starting Git HTTP proxy..."
exec 5< <(GITHUB_MCP_TOKEN="$token" GITHUB_MCP_OWNER="$owner" GITHUB_MCP_REPO="$repo" \
python3 "$script_dir/github-git-proxy.py")
_claude_vm_git_proxy_pid=$!
if ! read -r -t 5 _claude_vm_git_proxy_port <&5; then
echo "Warning: Git HTTP proxy failed to start" >&2
kill "$_claude_vm_git_proxy_pid" 2>/dev/null
exec 5<&-
# Non-fatal: MCP still works, just no git push
else
echo "Git HTTP proxy on port $_claude_vm_git_proxy_port (scope: $owner/$repo)"
fi
exec 5<&-

# Export for use by other functions
_claude_vm_github_owner="$owner"
_claude_vm_github_repo="$repo"
}

_claude_vm_inject_github_mcp() {
local vm_name="$1"
local port="$2"
limactl shell "$vm_name" bash -c "
CONFIG=\$HOME/.claude.json
if [ -f \"\$CONFIG\" ]; then
jq '.mcpServers.github = {\"type\":\"http\",\"url\":\"http://host.lima.internal:${port}/mcp\"}' \
\"\$CONFIG\" > \"\$CONFIG.tmp\" && mv \"\$CONFIG.tmp\" \"\$CONFIG\"
else
echo '{\"mcpServers\":{\"github\":{\"type\":\"http\",\"url\":\"http://host.lima.internal:${port}/mcp\"}}}' > \"\$CONFIG\"
fi
"
}

_claude_vm_inject_git_proxy() {
local vm_name="$1"
local git_port="$2"
local owner="$3"
local repo="$4"

# Configure git to route github.com through the proxy
limactl shell "$vm_name" bash -c "
git config --global url.\"http://host.lima.internal:${git_port}/\".insteadOf \"git@github.com:\"
git config --global url.\"http://host.lima.internal:${git_port}/\".insteadOf \"https://github.com/\"
"

# Write Claude instructions about git access
limactl shell "$vm_name" bash -c "
mkdir -p \$HOME/.claude
cat >> \$HOME/.claude/CLAUDE.md << 'INSTRUCTIONS'

# Git access

Git push and pull to GitHub work out of the box. The remote URL is automatically
rewritten to go through a host-side proxy that injects credentials. You can use
standard git commands:

git push origin main
git push origin HEAD:my-branch
git pull origin main

The proxy only allows access to the current repository (${owner}/${repo}).
Pushes to other repositories will be rejected.
INSTRUCTIONS
"
}

_claude_vm_security_snapshot() {
local host_dir="$1"
local snapshot_file="$2"
Expand Down Expand Up @@ -289,7 +415,14 @@ _claude_vm_security_check() {
}

claude-vm() {
local args=("$@")
local use_github=false
local args=()
while [[ $# -gt 0 ]]; do
case "$1" in
--github) use_github=true; shift ;;
*) args+=("$1"); shift ;;
esac
done
local vm_name="claude-$(basename "$(pwd)" | tr -cs 'a-zA-Z0-9' '-' | sed 's/^-//;s/-$//')-$$"
local host_dir="$(pwd)"

Expand All @@ -300,13 +433,19 @@ claude-vm() {

_claude_vm_start_proxy "$host_dir" || return 1

if $use_github; then
_claude_vm_start_github_mcp "$host_dir" || echo "Continuing without GitHub MCP..."
fi

# Security: snapshot before session
local security_snapshot
security_snapshot="$(mktemp)"

_claude_vm_cleanup() {
echo "Cleaning up VM..."
[ -n "$_claude_vm_proxy_pid" ] && kill "$_claude_vm_proxy_pid" 2>/dev/null
[ -n "$_claude_vm_github_mcp_pid" ] && kill "$_claude_vm_github_mcp_pid" 2>/dev/null
[ -n "$_claude_vm_git_proxy_pid" ] && kill "$_claude_vm_git_proxy_pid" 2>/dev/null
limactl stop "$vm_name" &>/dev/null
limactl delete "$vm_name" --force &>/dev/null
rm -f "$security_snapshot" "${security_snapshot}.git-config"
Expand All @@ -332,6 +471,14 @@ claude-vm() {

_claude_vm_write_dummy_credentials "$vm_name"

if $use_github && [ -n "$_claude_vm_github_mcp_port" ]; then
_claude_vm_inject_github_mcp "$vm_name" "$_claude_vm_github_mcp_port"
fi
if $use_github && [ -n "$_claude_vm_git_proxy_port" ]; then
_claude_vm_inject_git_proxy "$vm_name" "$_claude_vm_git_proxy_port" \
"$_claude_vm_github_owner" "$_claude_vm_github_repo"
fi

# Run project-specific runtime script if it exists
if [ -f "${host_dir}/.claude-vm.runtime.sh" ]; then
echo "Running project runtime setup..."
Expand All @@ -340,6 +487,7 @@ claude-vm() {

limactl shell --workdir "$host_dir" "$vm_name" \
env ANTHROPIC_BASE_URL="http://host.lima.internal:${_claude_vm_proxy_port}" \
IS_SANDBOX=1 \
claude --dangerously-skip-permissions "${args[@]}"
_claude_vm_security_check "$host_dir" "$security_snapshot"

Expand All @@ -348,6 +496,13 @@ claude-vm() {
}

claude-vm-shell() {
local use_github=false
while [[ $# -gt 0 ]]; do
case "$1" in
--github) use_github=true; shift ;;
*) shift ;;
esac
done
local vm_name="claude-debug-$$"
local host_dir="$(pwd)"

Expand All @@ -358,13 +513,19 @@ claude-vm-shell() {

_claude_vm_start_proxy "$host_dir" || return 1

if $use_github; then
_claude_vm_start_github_mcp "$host_dir" || echo "Continuing without GitHub MCP..."
fi

# Security: snapshot before session
local security_snapshot
security_snapshot="$(mktemp)"

_claude_vm_shell_cleanup() {
echo "Cleaning up VM..."
[ -n "$_claude_vm_proxy_pid" ] && kill "$_claude_vm_proxy_pid" 2>/dev/null
[ -n "$_claude_vm_github_mcp_pid" ] && kill "$_claude_vm_github_mcp_pid" 2>/dev/null
[ -n "$_claude_vm_git_proxy_pid" ] && kill "$_claude_vm_git_proxy_pid" 2>/dev/null
limactl stop "$vm_name" &>/dev/null
limactl delete "$vm_name" --force &>/dev/null
rm -f "$security_snapshot" "${security_snapshot}.git-config"
Expand All @@ -387,13 +548,27 @@ claude-vm-shell() {

_claude_vm_write_dummy_credentials "$vm_name"

if $use_github && [ -n "$_claude_vm_github_mcp_port" ]; then
_claude_vm_inject_github_mcp "$vm_name" "$_claude_vm_github_mcp_port"
fi
if $use_github && [ -n "$_claude_vm_git_proxy_port" ]; then
_claude_vm_inject_git_proxy "$vm_name" "$_claude_vm_git_proxy_port" \
"$_claude_vm_github_owner" "$_claude_vm_github_repo"
fi

# Run project-specific runtime script if it exists
if [ -f "${host_dir}/.claude-vm.runtime.sh" ]; then
limactl shell --workdir "$host_dir" "$vm_name" bash -l < "${host_dir}/.claude-vm.runtime.sh"
fi

echo "VM: $vm_name | Dir: $host_dir"
echo "API proxy: http://host.lima.internal:${_claude_vm_proxy_port}"
if [ -n "$_claude_vm_github_mcp_port" ]; then
echo "GitHub MCP: http://host.lima.internal:${_claude_vm_github_mcp_port}/mcp"
fi
if [ -n "$_claude_vm_git_proxy_port" ]; then
echo "Git proxy: http://host.lima.internal:${_claude_vm_git_proxy_port}"
fi
echo "Type 'exit' to stop and delete the VM"
limactl shell --workdir "$host_dir" "$vm_name" \
env ANTHROPIC_BASE_URL="http://host.lima.internal:${_claude_vm_proxy_port}" \
Expand Down
Loading