Experimental: This project is still in active development and subject to breaking changes.
Matchlock is a CLI tool for running AI agents in ephemeral microVMs - with network allowlisting, secret injection via MITM proxy, and VM-level isolation. Your secrets never enter the VM.
AI agents need to run code, but giving them unrestricted access to your machine is a risk. Matchlock lets you hand an agent a full Linux environment that boots in under a second - isolated and disposable.
When you pass --allow-host or --secret, Matchlock seals the network - only traffic to explicitly allowed hosts gets through, and everything else is blocked. When your agent calls an API the real credentials are injected in-flight by the host. The sandbox only ever sees a placeholder. Even if the agent is tricked into running something malicious your keys don't leak and there's nowhere for data to go. Inside the agent gets a full Linux environment to do whatever it needs. It can install packages and write files and make a mess. Host directories are never shared with the guest: supply inputs explicitly and retrieve outputs through the guest file APIs. Guest-local changes are disposable unless you attach a persistent block volume. Same CLI and same behaviour whether you're on a Linux server or a MacBook.
- Linux with KVM support
- macOS on Apple Silicon
See docs/install.md for full installation details.
Quick Installation
The script below detect OS, and install matchlock using Homebrew on Macos, and rpm/deb on Debian/RHEL flavourd Linux Distros
curl -fsSL https://raw.githubusercontent.com/jingkaihe/matchlock/main/scripts/install.sh | bash
# Or install a specific release
curl -fsSL https://raw.githubusercontent.com/jingkaihe/matchlock/main/scripts/install.sh | bash -s -- --version 0.2.4Homebrew
Homebrew based installation is supported on both macOS and Linux:
brew tap jingkaihe/essentials
brew install matchlockDebian / Ubuntu (.deb)
sudo dpkg -i ./matchlock_<version>_linux_amd64.deb
sudo apt-get install -f
matchlock diagnoseFedora / RHEL / CentOS Stream (.rpm)
sudo dnf install ./matchlock_<version>_linux_amd64.rpm
matchlock diagnoseIf matchlock diagnose reports missing host setup, run:
sudo matchlock setup linuxTo enroll a specific user explicitly, run:
sudo matchlock setup user <name># Basic
matchlock run --image alpine:latest cat /etc/os-release
matchlock run --image alpine:latest -it sh
matchlock run --image alpine:latest --no-network -- sh -lc 'echo offline'
# Network allowlist
matchlock run --image python:3.12-alpine \
--allow-host "api.openai.com" python agent.py
# Keep interception enabled even with an empty allowlist,
# so hosts can be added/removed at runtime.
matchlock run --image alpine:latest --rm=false --network-intercept
matchlock allow-list add <vm-id> api.openai.com,api.anthropic.com
matchlock allow-list delete <vm-id> api.openai.com
# Secret injection (never enters the VM)
export ANTHROPIC_API_KEY=sk-xxx
matchlock run --image python:3.12-alpine \
--secret [email protected] python call_api.py
# Long-lived sandboxes
matchlock run --image alpine:latest --rm=false # prints VM ID
matchlock run --image nginx:latest -d # same as above, detached
matchlock exec vm-abc12345 -it sh # attach to it
matchlock port-forward vm-abc12345 8080:8080 # forward host:8080 -> guest:8080
# Publish ports at startup
matchlock run --image alpine:latest --rm=false -p 8080:8080
# Lifecycle
matchlock list | kill | rm | prune
# Build from Dockerfile (uses BuildKit-in-VM)
matchlock build -f Dockerfile -t myapp:latest .
# Pull a registry image (caches for faster startup)
matchlock pull alpine:latest
# Image management
matchlock image ls # List all images
matchlock image rm myapp:latest # Remove a local image
docker save myapp:latest | matchlock image import myapp:latest # Import from tarballMatchlock does not support host-directory mounts or filesystem interception. Copy files in and out explicitly with matchlock cp, run --copy-in / --copy-out, or the SDK copy and file APIs. Filesystem operations happen on native guest storage. Use matchlock volume with run --disk @name:/guest/path for persistent storage. See the migration guide.
# Copy ./src in, run the build, and copy the results out
matchlock run --image alpine:latest --copy-in ./src/.:/workspace --copy-out /workspace/dist:./dist -w /workspace -- make
# Copy files to and from a running sandbox
matchlock cp ./src/. vm-abc12345:/workspace
matchlock cp vm-abc12345:/workspace/dist ./distmatchlock build runs BuildKit inside a VM and imports the result into the local image store. The build context is copied into the VM, honouring .dockerignore and Dockerfile-specific ignore files, so --build-disk must be large enough to hold it. The build cache persists between builds, and no host directory is shared with the VM.
Matchlock ships Go, Python, and TypeScript SDKs for embedding sandboxes directly in your application. You can launch VMs, execute commands, stream output, and manage files programmatically.
Go
package main
import (
"context"
"fmt"
"os"
"github.com/jingkaihe/matchlock/pkg/sdk"
)
func main() {
ctx := context.Background()
client, err := sdk.NewClient(sdk.DefaultConfig())
if err != nil {
panic(err)
}
defer client.Close(0)
defer client.Remove()
sandbox := sdk.New("alpine:latest").
AllowHost("dl-cdn.alpinelinux.org", "api.anthropic.com").
AddSecret("ANTHROPIC_API_KEY", os.Getenv("ANTHROPIC_API_KEY"), "api.anthropic.com")
if _, err := client.Launch(sandbox); err != nil {
panic(err)
}
if _, err := client.Exec(ctx, "apk add --no-cache curl"); err != nil {
panic(err)
}
// The VM only ever sees a placeholder - the real key never enters the sandbox
result, err := client.Exec(ctx, "echo $ANTHROPIC_API_KEY")
if err != nil {
panic(err)
}
fmt.Print(result.Stdout) // prints "SANDBOX_SECRET_a1b2c3d4..."
curlCmd := `curl -s --no-buffer https://api.anthropic.com/v1/messages \
-H "content-type: application/json" \
-H "x-api-key: $ANTHROPIC_API_KEY" \
-H "anthropic-version: 2023-06-01" \
-d '{"model":"claude-haiku-4-5-20251001","max_tokens":1024,"stream":true,
"messages":[{"role":"user","content":"Explain TCP to me"}]}'`
if _, err := client.ExecStream(ctx, curlCmd, os.Stdout, os.Stderr); err != nil {
panic(err)
}
}Go SDK private-IP behavior (10/8, 172.16/12, 192.168/16):
- Default (unset): private IPs are blocked whenever a network config is sent.
- Explicit block: call
.WithBlockPrivateIPs(true)(or.BlockPrivateIPs()). - Explicit allow: call
.AllowPrivateIPs()or.WithBlockPrivateIPs(false).
sandbox := sdk.New("alpine:latest").
AllowHost("api.openai.com").
AddHost("api.internal", "10.0.0.10").
WithNetworkMTU(1200).
AllowPrivateIPs() // explicit override: block_private_ips=false
// SDK network interception (request/response mutation, body shaping, SSE data-line transform)
sandbox = sandbox.WithNetworkInterception(&sdk.NetworkInterceptionConfig{
Rules: []sdk.NetworkHookRule{
{
Phase: sdk.NetworkHookPhaseBefore,
Action: sdk.NetworkHookActionMutate,
Hosts: []string{"api.openai.com"},
SetHeaders: map[string]string{"X-Trace-Id": "trace-123"},
},
{
Phase: sdk.NetworkHookPhaseAfter,
Action: sdk.NetworkHookActionMutate,
Hosts: []string{"api.openai.com"},
BodyReplacements: []sdk.NetworkBodyTransform{
{Find: "internal-id", Replace: "redacted"},
},
},
},
})If you use client.Create(...) directly (without the builder), set:
BlockPrivateIPsSet: trueBlockPrivateIPs: false(ortrue)
For fully offline sandboxes (no guest NIC / no egress), use:
- CLI:
--no-network - Go SDK builder:
.WithNoNetwork() - Python SDK builder:
.with_no_network() - TypeScript SDK builder:
.withNoNetwork()
Python (PyPI)
pip install matchlock
# or
uv add matchlockimport os
import sys
from matchlock import Client, Sandbox
sandbox = (
Sandbox("python:3.12-alpine")
.allow_host(
"dl-cdn.alpinelinux.org",
"files.pythonhosted.org", "pypi.org",
"astral.sh", "github.com", "objects.githubusercontent.com",
"api.anthropic.com",
)
.add_secret(
"ANTHROPIC_API_KEY", os.environ["ANTHROPIC_API_KEY"], "api.anthropic.com"
)
)
SCRIPT = """\
# /// script
# requires-python = ">=3.12"
# dependencies = ["anthropic"]
# ///
import anthropic, os
client = anthropic.Anthropic(api_key=os.environ["ANTHROPIC_API_KEY"])
with client.messages.stream(
model="claude-haiku-4-5-20251001",
max_tokens=1024,
messages=[{"role": "user", "content": "Explain TCP/IP."}],
) as stream:
for text in stream.text_stream:
print(text, end="", flush=True)
print()
"""
with Client() as client:
client.launch(sandbox)
client.exec("pip install --quiet uv")
client.write_file("/workspace/ask.py", SCRIPT)
client.exec_stream("uv run /workspace/ask.py", stdout=sys.stdout, stderr=sys.stderr)
client.remove()TypeScript
npm install matchlock-sdkimport { Client, Sandbox } from "matchlock-sdk";
const SCRIPT = `import Anthropic from "@anthropic-ai/sdk";
const anthropic = new Anthropic({
apiKey: process.env.ANTHROPIC_API_KEY,
});
const stream = anthropic.messages
.stream({
model: "claude-haiku-4-5-20251001",
max_tokens: 1024,
messages: [{ role: "user", content: "Explain TCP/IP." }],
})
.on("text", (text) => {
process.stdout.write(text);
});
await stream.finalMessage();
process.stdout.write("\\n");
`;
const client = new Client();
try {
const sandbox = new Sandbox("node:22-alpine")
.allowHost("registry.npmjs.org", "*.npmjs.org", "api.anthropic.com")
.addSecret("ANTHROPIC_API_KEY", process.env.ANTHROPIC_API_KEY ?? "", "api.anthropic.com");
await client.launch(sandbox);
await client.exec(
"mkdir -p /workspace && cd /workspace && npm init -y >/dev/null 2>&1 && npm install --quiet @anthropic-ai/sdk",
);
await client.writeFile("/workspace/ask.mjs", SCRIPT);
await client.execStream("node ask.mjs", {
workingDir: "/workspace",
stdout: process.stdout,
stderr: process.stderr,
});
} finally {
await client.close();
await client.remove();
}More examples in the examples/ directory:
| Description | Example |
|---|---|
| Streams Anthropic API response with secret injection (Go) | examples/go/basic/ |
| Interactive terminal with PTY using ExecInteractive (Go) | examples/go/exec_modes/ |
| Injects API key via network interception hook (Go) | examples/go/network_interception/ |
| Streams Anthropic API response (Python) | examples/python/basic/ |
| Stream, pipe, and interactive execution modes (Python) | examples/python/exec_modes/ |
| Injects API key via network interception hook (Python) | examples/python/network_interception/ |
| Streams Anthropic API response (TypeScript) | examples/typescript/basic/ |
| Stream, pipe, and interactive execution modes (TypeScript) | examples/typescript/exec_modes/ |
| Injects API key via network interception hook (TypeScript) | examples/typescript/network_interception/ |
| Claude Code CLI in micro-VM with GitHub bootstrap | examples/claude-code/ |
| Claude Code with Docker inside sandbox via SDK | examples/claude-code-with-docker/ |
| Claude Code with Claude Pro/Max subscription in the sandbox | examples/claude-danger/ |
| OpenAI Codex CLI in micro-VM with GitHub bootstrap | examples/codex/ |
| Docker daemon inside sandbox with systemd | examples/docker-in-sandbox/ |
| Streamlit chatbot using Agent Client Protocol | examples/agent-client-protocol/ |
| Browser automation with Kodelet and Playwright MCP | examples/playwright/ |
╭──────────────────────────╮ ╭───────────────────────────╮
│ Host │ │ Micro-VM │
│ │ │ │
│ Matchlock CLI / SDK │── vsock :5000 ─▶│ Guest agent │
│ Explicit file transfers │◀────────────────│ Native guest filesystem │
│ │ │ OCI image + block volumes │
│ Policy + HTTP/TLS proxy │◀── networking ──│ Guest applications │
╰──────────────────────────╯ ╰───────────────────────────╯
| Platform | Mode | Mechanism |
|---|---|---|
| Linux | Transparent proxy | nftables DNAT on ports 80/443 |
| macOS | NAT (default) | Virtualization.framework built-in NAT |
| macOS | Interception (with --allow-host/--secret) |
gVisor userspace TCP/IP at L4 |
- Lifecycle and Cleanup Runbook
- Network Interception
- VFS Mounts Removal and Migration Guide
- Developer Reference
MIT