491 lines
14 KiB
Go
491 lines
14 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"
|
|
"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)
|
|
// Every argument belongs to claude or --exec, as in the bash launcher.
|
|
code := launch(ctx, os.Args[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"))
|
|
}
|
|
|
|
// runtimeDir holds the session dirs, including the private API socket's.
|
|
func runtimeDir() string {
|
|
return envOr("XDG_RUNTIME_DIR", "/tmp")
|
|
}
|
|
|
|
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
|
|
}
|
|
allowlist := allowlistPath()
|
|
src, err := checkLaunchDir(pwd, allowlist)
|
|
if err != nil {
|
|
warn("%v", err)
|
|
return 1
|
|
}
|
|
if strings.Contains(pwd+src, ":") {
|
|
warn("cannot mount a directory containing ':'")
|
|
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 := runtimeDir()
|
|
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, src, 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
|
|
}
|
|
|
|
// runSession mounts the checked src at pwd, the path the user launched from.
|
|
func runSession(ctx context.Context, eng engine, session, tmp, src, 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: src, 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
|
|
}
|
|
|
|
var forbiddenRoots = []string{"/run", "/tmp", "/var/tmp", "/proc", "/sys", "/dev"}
|
|
|
|
func allowedPathsFile() string {
|
|
return filepath.Join(configDir(), "allowed-paths")
|
|
}
|
|
|
|
// sensitiveDirs are host dirs an allowed-paths root may neither contain nor sit under.
|
|
func sensitiveDirs(home string) []string {
|
|
dirs := []string{home + "/.ssh", home + "/.gnupg", home + "/.config", filepath.Dir(configDir()),
|
|
home + "/.local/share/containers", runtimeDir()}
|
|
if v := os.Getenv("XDG_DATA_HOME"); v != "" {
|
|
dirs = append(dirs, v+"/containers")
|
|
}
|
|
return dirs
|
|
}
|
|
|
|
// overlapsSensitive returns the sensitive dir that p is, sits under or contains.
|
|
func overlapsSensitive(p, home string) string {
|
|
for _, s := range sensitiveDirs(home) {
|
|
if s = realpath(s); inside(p, s) || inside(s, p) {
|
|
return s
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// allowedRoots reads the launch-dir roots; without the file the only root is
|
|
// $HOME and def is true.
|
|
func allowedRoots(home string) (roots []string, def bool, err error) {
|
|
b, err := os.ReadFile(allowedPathsFile())
|
|
if os.IsNotExist(err) {
|
|
return []string{home}, true, nil
|
|
}
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
for _, l := range strings.Split(string(b), "\n") {
|
|
l = strings.TrimSpace(l)
|
|
if l == "" || strings.HasPrefix(l, "#") {
|
|
continue
|
|
}
|
|
r := l
|
|
if l == "~" || strings.HasPrefix(l, "~/") {
|
|
r = home + l[1:]
|
|
}
|
|
if !filepath.IsAbs(r) {
|
|
return nil, false, fmt.Errorf("%s: %q is not an absolute path", allowedPathsFile(), l)
|
|
}
|
|
r = realpath(r)
|
|
if inside(home, r) {
|
|
return nil, false, fmt.Errorf("%s: %q is or contains $HOME", allowedPathsFile(), l)
|
|
}
|
|
if filepath.Dir(r) == home && strings.HasPrefix(filepath.Base(r), ".") {
|
|
return nil, false, fmt.Errorf("%s: %q is a dot-directory of $HOME", allowedPathsFile(), l)
|
|
}
|
|
for _, bad := range forbiddenRoots {
|
|
if bad = realpath(bad); inside(r, bad) || inside(bad, r) {
|
|
return nil, false, fmt.Errorf("%s: %q overlaps %s", allowedPathsFile(), l, bad)
|
|
}
|
|
}
|
|
for _, d := range pathDirs() {
|
|
if inside(d, r) {
|
|
return nil, false, fmt.Errorf("%s: %q is or contains $PATH entry %s", allowedPathsFile(), l, d)
|
|
}
|
|
}
|
|
if s := overlapsSensitive(r, home); s != "" {
|
|
return nil, false, fmt.Errorf("%s: %q overlaps %s", allowedPathsFile(), l, s)
|
|
}
|
|
roots = append(roots, r)
|
|
}
|
|
return roots, false, nil
|
|
}
|
|
|
|
// pathDirs resolves $PATH; a sandbox-written file there would run on the host.
|
|
func pathDirs() []string {
|
|
var dirs []string
|
|
for _, d := range filepath.SplitList(os.Getenv("PATH")) {
|
|
dirs = append(dirs, realpath(d))
|
|
}
|
|
return dirs
|
|
}
|
|
|
|
// checkLaunchDir returns pwd resolved if it is strictly under an allowed root,
|
|
// never $HOME itself, anything holding the sandbox's config, the API socket or
|
|
// a $PATH entry, nor anything overlapping a forbidden system or sensitive dir. Under the
|
|
// default $HOME root, dot-dirs of $HOME are refused too.
|
|
func checkLaunchDir(pwd, allowlist string) (string, error) {
|
|
pwd = realpath(pwd)
|
|
home := realpath(os.Getenv("HOME"))
|
|
roots, def, err := allowedRoots(home)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if inside(home, pwd) {
|
|
return "", fmt.Errorf("refusing to mount %s: it is or contains $HOME", pwd)
|
|
}
|
|
if rel, ok := strings.CutPrefix(pwd, home+"/"); def && ok && strings.HasPrefix(rel, ".") {
|
|
return "", fmt.Errorf("refusing to mount %s: it is under a dot-directory of $HOME", pwd)
|
|
}
|
|
paths := allowedPathsFile()
|
|
for _, dir := range append([]string{configDir(), filepath.Dir(allowlist), filepath.Dir(realpath(allowlist)),
|
|
filepath.Dir(realpath(paths)), runtimeDir()}, pathDirs()...) {
|
|
if dir = realpath(dir); inside(dir, pwd) {
|
|
return "", fmt.Errorf("refusing to mount %s: it contains %s", pwd, dir)
|
|
}
|
|
}
|
|
for _, bad := range forbiddenRoots {
|
|
if bad = realpath(bad); inside(pwd, bad) || inside(bad, pwd) {
|
|
return "", fmt.Errorf("refusing to mount %s: it overlaps %s", pwd, bad)
|
|
}
|
|
}
|
|
if s := overlapsSensitive(pwd, home); s != "" {
|
|
return "", fmt.Errorf("refusing to mount %s: it overlaps %s", pwd, s)
|
|
}
|
|
for _, r := range roots {
|
|
if pwd != r && inside(pwd, r) {
|
|
return pwd, nil
|
|
}
|
|
}
|
|
return "", fmt.Errorf("refusing to mount %s: it is not under an allowed path (%s)", pwd, paths)
|
|
}
|
|
|
|
// 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
|
|
}
|