393 lines
11 KiB
Go
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
|
|
}
|