Files
agent-sandbox/main.go
T
unkin-agent af55d83de6
ci/woodpecker/pr/lint Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
Port the launcher to Go on the podman API bindings
2026-10-24 16:49:43 +11:00

393 lines
11 KiB
Go

// agent-sandbox runs Claude Code in a rootless podman sandbox whose only way
// out is a per-session router that resolves and forwards allowlisted hosts.
package main
import (
"context"
_ "embed"
"fmt"
"net"
"os"
"os/signal"
"path/filepath"
"strings"
"syscall"
"time"
"github.com/containers/podman/v5/libpod/define"
"github.com/containers/podman/v5/pkg/specgen"
spec "github.com/opencontainers/runtime-spec/specs-go"
"github.com/spf13/cobra"
"go.podman.io/common/libnetwork/types"
"golang.org/x/term"
)
const (
registry = "artifactapi.k8s.syd1.au.unkin.net/docker-internal"
egressNet = "agent-sandbox-egress"
)
var version = "dev"
//go:embed allowed-hosts
var defaultAllowlist []byte
func main() {
// A closed stdout must end the session through cleanup, not kill us.
signal.Ignore(syscall.SIGPIPE)
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP)
code := 0
root := &cobra.Command{
Use: "agent-sandbox [--exec CMD...] [claude args...]",
Short: "Run Claude Code in a rootless podman sandbox with allowlisted egress",
Args: cobra.ArbitraryArgs,
DisableFlagParsing: true,
SilenceUsage: true,
Run: func(_ *cobra.Command, args []string) { code = launch(ctx, args) },
}
root.AddCommand(&cobra.Command{
Use: "version",
Short: "Print the version",
Run: func(*cobra.Command, []string) { fmt.Println(version) },
})
if err := root.Execute(); err != nil {
code = 1
}
stop()
os.Exit(code)
}
func imageNames() (string, string) {
img, router := registry+"/agent-sandbox:"+version, registry+"/agent-sandbox-router:"+version
if version == "dev" {
img, router = "localhost/agent-sandbox:dev", "localhost/agent-sandbox-router:dev"
}
return envOr("AGENT_SANDBOX_IMAGE", img), envOr("AGENT_SANDBOX_ROUTER_IMAGE", router)
}
func envOr(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
func configDir() string {
return filepath.Join(envOr("XDG_CONFIG_HOME", filepath.Join(os.Getenv("HOME"), ".config")), "agent-sandbox")
}
func allowlistPath() string {
return envOr("AGENT_SANDBOX_ALLOWLIST", filepath.Join(configDir(), "allowed-hosts"))
}
func warn(format string, a ...any) {
fmt.Fprintf(os.Stderr, "agent-sandbox: "+format+"\n", a...)
}
// launch runs one session and returns the process exit code.
func launch(ctx context.Context, args []string) int {
cmd := append([]string{"claude"}, args...)
if len(args) > 0 && args[0] == "--exec" {
cmd = args[1:]
}
pwd, err := os.Getwd()
if err != nil {
warn("%v", err)
return 1
}
if strings.Contains(pwd, ":") {
warn("cannot mount a directory containing ':'")
return 1
}
allowlist := allowlistPath()
if err := checkLaunchDir(pwd, allowlist); err != nil {
warn("%v", err)
return 1
}
if err := ensureAllowlist(allowlist); err != nil {
warn("%v", err)
return 1
}
entries, err := parseAllowlist(allowlist)
if err != nil {
warn("%v", err)
return 1
}
session := fmt.Sprintf("agent-sandbox-%d-%d", time.Now().Unix(), os.Getpid())
runtimeDir := envOr("XDG_RUNTIME_DIR", "/tmp")
tmp, err := os.MkdirTemp(runtimeDir, session+".")
if err != nil {
warn("%v", err)
return 1
}
defer func() { _ = os.RemoveAll(tmp) }()
// The API socket lives apart from tmp, which is mounted into the router.
apiDir, err := os.MkdirTemp(runtimeDir, session+"-api.")
if err != nil {
warn("%v", err)
return 1
}
defer func() { _ = os.RemoveAll(apiDir) }()
conn, eng, stopService, err := startService(apiDir)
if err != nil {
warn("%v", err)
return 125
}
defer stopService()
defer func() {
_ = eng.PodRemove(conn, session)
_ = eng.ContainerRemove(conn, session+"-router")
_ = eng.NetworkRemove(conn, session)
}()
opCtx, cancel := context.WithCancel(conn)
defer cancel()
defer context.AfterFunc(ctx, cancel)()
code := runSession(opCtx, eng, session, tmp, pwd, entries, cmd)
if ctx.Err() != nil {
return 130
}
return code
}
// fail reports a podman API error with podman's own exit code for it.
func fail(err error) int {
warn("%v", err)
return 125
}
func runSession(ctx context.Context, eng engine, session, tmp, pwd string, entries []entry, cmd []string) int {
image, routerImage := imageNames()
homeVolume := envOr("AGENT_SANDBOX_HOME_VOLUME", "agent-sandbox-home")
router := session + "-router"
yes := true
if _, err := eng.NetworkCreate(ctx, &types.Network{Name: egressNet, Driver: "bridge", DNSEnabled: true}, true); err != nil {
return fail(err)
}
if err := eng.VolumeCreate(ctx, homeVolume); err != nil {
return fail(err)
}
n, err := eng.NetworkCreate(ctx, &types.Network{Name: session, Driver: "bridge", Internal: true}, false)
if err != nil {
return fail(err)
}
subnet := ""
for _, s := range n.Subnets {
subnet += s.Subnet.String()
}
if !strings.HasSuffix(subnet, ".0/24") {
warn("unexpected subnet %s", subnet)
return 1
}
rip := strings.TrimSuffix(subnet, ".0/24") + ".2"
dnsmasq, nginx, forwards := routerConfig(entries, rip)
for name, body := range map[string]string{"dnsmasq.conf": dnsmasq, "nginx.conf": nginx} {
f := filepath.Join(tmp, name)
err := os.WriteFile(f, []byte(body), 0o644)
if err == nil {
err = os.Chmod(f, 0o644)
}
if err != nil {
warn("%v", err)
return 1
}
}
if err := os.Chmod(tmp, 0o755); err != nil {
warn("%v", err)
return 1
}
r := newSpec(routerImage, router)
r.Remove = &yes
r.NetNS = specgen.Namespace{NSMode: specgen.Bridge}
r.Networks = map[string]types.PerNetworkOptions{
session: {StaticIPs: []net.IP{net.ParseIP(rip)}},
egressNet: {},
}
r.Mounts = []spec.Mount{{Type: "bind", Source: tmp, Destination: "/etc/router", Options: []string{"ro", "Z"}}}
if err := eng.ContainerCreate(ctx, r); err != nil {
return fail(err)
}
if err := eng.ContainerStart(ctx, router); err != nil {
return fail(err)
}
if err := waitPort(ctx, eng, router, rip, "443"); err != nil {
warn("%v", err)
return 1
}
// The pod owns the netns, DNS and hosts file; claude only joins it.
userns, _ := specgen.ParseUserNamespace("keep-id:uid=1000,gid=1000")
p := &specgen.PodSpecGenerator{}
p.Name = session
p.Hostname = "agent-sandbox"
p.NetNS = specgen.Namespace{NSMode: specgen.Bridge}
p.Networks = map[string]types.PerNetworkOptions{session: {}}
p.DNSServer = []net.IP{net.ParseIP(rip)}
p.NoManageHosts = true
p.Userns = userns
if err := eng.PodCreate(ctx, p); err != nil {
return fail(err)
}
// Loopback forwarders live in their own container: claude can't see, signal
// or replace them. Claude runs unprivileged; podman's default bounding set
// (no NET_ADMIN/NET_RAW/SYS_ADMIN) stays only so nested containers work.
f := newSpec(routerImage, session+"-forwarder")
f.Pod = session
f.User = "0:0"
f.ReadOnlyFilesystem = &yes
f.ReadWriteTmpfs = &yes
f.CapDrop = []string{"all"}
f.CapAdd = []string{"NET_BIND_SERVICE"}
f.Entrypoint = []string{"/usr/local/bin/forwarder"}
f.Env = map[string]string{"FORWARDS": forwards}
if err := eng.ContainerCreate(ctx, f); err != nil {
return fail(err)
}
if err := eng.ContainerStart(ctx, f.Name); err != nil {
return fail(err)
}
if err := waitPort(ctx, eng, f.Name, "127.0.0.1", "443"); err != nil {
warn("%v", err)
return 1
}
c := newSpec(image, session+"-claude")
c.Remove = &yes
c.Stdin = &yes
c.Terminal = ptr(term.IsTerminal(int(os.Stdin.Fd())) && term.IsTerminal(int(os.Stdout.Fd())))
c.Init = &yes
c.Pod = session
c.CapDrop = []string{"NET_ADMIN", "NET_RAW", "SYS_ADMIN"}
c.SelinuxOpts = []string{"disable"}
c.Annotations = map[string]string{define.InspectAnnotationLabel: "disable"}
c.Devices = []spec.LinuxDevice{{Path: "/dev/fuse"}, {Path: "/dev/net/tun"}}
c.Volumes = []*specgen.NamedVolume{{Name: homeVolume, Dest: "/home/claude"}}
c.Mounts = []spec.Mount{{Type: "bind", Source: pwd, Destination: pwd}}
c.WorkDir = pwd
c.Env = map[string]string{"DISABLE_AUTOUPDATER": "1", "CLAUDE_CODE_TMPDIR": "/var/tmp"}
c.Command = cmd
if err := eng.ContainerCreate(ctx, c); err != nil {
return fail(err)
}
code, err := eng.ContainerRun(ctx, c.Name)
if err != nil {
warn("%v", err)
}
return code
}
func ptr[T any](v T) *T { return &v }
// newSpec mirrors `podman run` defaults the API leaves to the client.
func newSpec(image, name string) *specgen.SpecGenerator {
s := specgen.NewSpecGenerator(image, false)
s.RawImageName = image
s.Name = name
s.HTTPProxy = ptr(true)
s.SdNotifyMode = define.SdNotifyModeContainer
return s
}
func waitPort(ctx context.Context, eng engine, container, ip, port string) error {
for range 50 {
if code, err := eng.ContainerExec(ctx, container, []string{"nc", "-z", ip, port}); err == nil && code == 0 {
return nil
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(100 * time.Millisecond):
}
}
return fmt.Errorf("%s did not start: %s", container, eng.ContainerLogs(context.WithoutCancel(ctx), container, 5))
}
// ensureAllowlist seeds a missing allowlist from the packaged default.
func ensureAllowlist(path string) error {
if fi, err := os.Stat(path); err == nil && fi.Mode().IsRegular() {
return nil
}
src := defaultAllowlist
if exe, err := os.Executable(); err == nil {
for _, p := range []string{filepath.Join(filepath.Dir(exe), "allowed-hosts"), "/usr/share/agent-sandbox/allowed-hosts"} {
if b, err := os.ReadFile(p); err == nil {
src = b
break
}
}
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
if err := os.WriteFile(path, src, 0o644); err != nil {
return err
}
warn("created %s from defaults", path)
return nil
}
// checkLaunchDir refuses launch dirs that would mount $HOME or the allowlist
// read-write.
func checkLaunchDir(pwd, allowlist string) error {
pwd = realpath(pwd)
if inside(realpath(os.Getenv("HOME")), pwd) {
return fmt.Errorf("refusing to mount %s: it is or contains $HOME", pwd)
}
for _, dir := range []string{configDir(), filepath.Dir(allowlist), filepath.Dir(realpath(allowlist))} {
if dir = realpath(dir); inside(dir, pwd) {
return fmt.Errorf("refusing to mount %s: it contains %s", pwd, dir)
}
}
return nil
}
// inside reports whether path p is dir or lies under it.
func inside(p, dir string) bool {
return p == dir || strings.HasPrefix(p, strings.TrimSuffix(dir, "/")+"/")
}
// realpath resolves every symlink in p like `realpath -m`: missing components
// are allowed, and ".." applies to the resolved path.
func realpath(p string) string {
if !filepath.IsAbs(p) {
wd, _ := os.Getwd()
p = filepath.Join(wd, p)
}
rest := strings.Split(p, "/")
resolved := "/"
for hops := 0; len(rest) > 0; {
part := rest[0]
rest = rest[1:]
switch part {
case "", ".":
continue
case "..":
resolved = filepath.Dir(resolved)
continue
}
next := filepath.Join(resolved, part)
fi, err := os.Lstat(next)
if err != nil || fi.Mode()&os.ModeSymlink == 0 || hops > 255 {
resolved = next
continue
}
target, err := os.Readlink(next)
if err != nil {
resolved = next
continue
}
hops++
if filepath.IsAbs(target) {
resolved = "/"
}
rest = append(strings.Split(target, "/"), rest...)
}
return resolved
}