Download
1197 lines 36.9 KiBGoModified 2026-10-31 raw
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197
// Package sandbox runs commands under the kernel-enforced sandbox a policy
// describes: bubblewrap on Linux, Seatbelt (sandbox-exec) on macOS. Each
// command runs in one of two modes: safe, with only the policy's safe
// access, or unsafe, with its confirm access too. Network access goes
// through a per-mode allowlisting proxy.
package sandbox

import (
	"bytes"
	"context"
	"crypto/rand"
	"encoding/hex"
	"errors"
	"fmt"
	"io"
	"io/fs"
	"net"
	"os"
	"os/exec"
	"path/filepath"
	"runtime"
	"slices"
	"strings"
	"sync"
	"syscall"
	"time"

	"blinker/internal/policy"
	"blinker/internal/proxy"
	"blinker/internal/tooldef"
	"blinker/policies"
)

// DomainAnswer is the user's answer to a command wanting a domain.
type DomainAnswer int

const (
	DomainDeny         DomainAnswer = iota
	DomainAllowOnce                 // for the rest of this command
	DomainAllowSession              // for unsafe commands, until blinker exits
	DomainAllowAlways               // with a rule in the project's unsafe policy
)

// Options configure a Sandbox.
type Options struct {
	ConfigDir    string   // holds the policies; on the floor
	StateDir     string   // holds history; on the floor
	DataDirs     []string // hold what blinker is installed with, such as policies; on the floor
	ScratchRoot  string   // where the session's dir goes; "" for the platform's
	ProxyPorts   []int
	AllowPrivate bool // let the proxy reach private addresses
	ExtraFloor   policy.Floor
	// Named, if set, is the only policy, in place of the global and
	// project ones: the sandbox command's. Commands get none of the variables
	// that say an agent runs them.
	Named *policy.Layer
	// AskDomain asks the user about a domain an unsafe command wants and
	// no rule mentions. Nil denies.
	AskDomain func(ctx context.Context, host string) DomainAnswer
	// Log, if non-nil, receives proxy and sandbox log lines.
	Log func(string)
}

// Sandbox is the sandbox for one session in one project.
type Sandbox struct {
	opts    Options
	home    string
	project string
	// session is the session's own dir. It holds the scratch dir, which
	// commands see as their temp dir (tmp), and netDir, for proxy sockets,
	// beside it so commands cannot replace them.
	session string
	root    string // holds every session's dir, and the mount lock (see removeMountPoints)
	hidden  bool   // the project is home or a system temp dir; see Hidden
	empty   string // an empty file, for guard
	scratch string
	tmp     string
	netDir  string

	plat           platform
	shell, cat, sh string

	mu       sync.Mutex
	loaded   *policy.Loaded
	pol      policy.Pair
	tools    []tooldef.Def
	allowed  map[string]bool // hosts the user allowed for the session
	floor    policy.Floor
	proxies  map[*modeProxy]bool // those running, one per command
	active   *activeCommand
	unsafeMu sync.Mutex // one unsafe command at a time, so domain prompts belong to it
}

// modeProxy is a command's proxy, so the hosts it refuses are the
// command's.
type modeProxy struct {
	srv    *proxy.Server
	addr   net.Addr // where it listens, for the platform
	dir    string   // the directory made for it (see platform.ListenProxy)
	denied []string // hosts refused for not being approved; guarded by Sandbox.mu
}

// activeCommand is the unsafe command running now, for domain prompts.
type activeCommand struct {
	ctx       context.Context
	questions map[string]*question
}

type question struct {
	once sync.Once
	ok   bool
}

const (
	sessionPrefix   = "blinker-session-"
	proxySocketName = "proxy.sock"
)

// New sets up the sandbox for the project in the current directory.
func New(opts Options) (*Sandbox, error) {
	exe, err := os.Executable()
	if err != nil {
		return nil, err
	}
	if exe, err = filepath.EvalSymlinks(exe); err != nil {
		return nil, err
	}
	plat, err := hostPlatform(exe)
	if err != nil {
		return nil, err
	}
	return newSandbox(opts, plat)
}

// newSandbox sets up the sandbox on a platform.
func newSandbox(opts Options, plat platform) (*Sandbox, error) {
	s := &Sandbox{opts: opts, plat: plat, proxies: map[*modeProxy]bool{}}
	var err error
	if s.home, err = os.UserHomeDir(); err != nil {
		return nil, err
	}
	if s.home, err = filepath.EvalSymlinks(s.home); err != nil {
		return nil, err
	}
	wd, err := os.Getwd()
	if err != nil {
		return nil, err
	}
	if s.project, err = filepath.EvalSymlinks(wd); err != nil {
		return nil, err
	}
	sys := plat.SystemReadPaths()
	s.shell = systemTool("bash", "/bin/bash", sys)
	s.cat = systemTool("cat", "/bin/cat", sys)
	s.sh = systemTool("sh", "/bin/sh", sys)

	homes, err := plat.UserHomes()
	if err != nil {
		return nil, fmt.Errorf("listing users' homes: %w", err)
	}
	// Run from a home (its own or another user's) or a shared temp dir,
	// blinker is for asking questions, not for working on files.
	s.hidden = s.project == s.home || slices.Contains(homes, s.project) || slices.Contains(plat.SystemTempDirs(), s.project)

	root := opts.ScratchRoot
	if root == "" {
		if root, err = plat.ScratchRoot(); err != nil {
			return nil, fmt.Errorf("scratch root: %w", err)
		}
	}
	if root, err = filepath.EvalSymlinks(root); err != nil {
		return nil, fmt.Errorf("scratch root: %w", err)
	}
	// The root holds every session's dir, with each mode's proxy socket:
	// a command that could reach the unsafe mode's would get its network.
	// It goes on the floor (the session's own scratch dir stays open), so
	// it must not hold the places commands work in. A hidden project is on
	// the floor anyway.
	if (policy.Under(s.project, root) && !s.hidden) || policy.Under(s.home, root) {
		return nil, fmt.Errorf("the scratch root %s must not contain the project or home", root)
	}
	s.root = root
	s.cleanupStale(root)
	if err := s.makeSessionDir(filepath.Join(root, sessionPrefix+randomID())); err != nil {
		s.Close()
		return nil, err
	}
	s.tmp = plat.TempDir(s.scratch, s.project)
	if err := plat.PrepareScratch(s.scratch); err != nil {
		s.logf("preparing the temp dir: %v", err)
	}

	s.floor = policy.DefaultFloor(append([]string{opts.ConfigDir, opts.StateDir}, opts.DataDirs...)...)
	// Other users' homes are no command's business: run as root, nothing
	// else keeps a command out of them, and ~ is root's. One holding the
	// project or this home (sudo blinker in alice's project) cannot be
	// hidden, so it gets the floor this home has: its credentials and
	// startup files. The system's secrets are hidden for the same reason.
	for _, h := range homes {
		switch {
		case h == s.home:
		case policy.Under(s.project, h) || policy.Under(s.home, h):
			other := policy.DefaultFloor().InHome(h)
			s.floor.Blocklist = append(s.floor.Blocklist, other.Blocklist...)
			s.floor.NeverWrite = append(s.floor.NeverWrite, other.NeverWrite...)
		default:
			s.floor.Blocklist = append(s.floor.Blocklist, h)
		}
	}
	s.floor.Blocklist = append(s.floor.Blocklist, plat.SystemSecrets()...)
	for _, dir := range plat.SystemTempDirs() {
		// The one holding the project is the project's surroundings, which
		// the policy decides as it does anywhere else. One that is the
		// project is hidden, below.
		if !policy.Under(s.project, dir) {
			s.floor.Blocklist = append(s.floor.Blocklist, dir)
		}
	}
	s.floor.Blocklist = append(s.floor.Blocklist, root)
	if s.hidden {
		// No rule, not even one for the project, opens it.
		s.floor.Blocklist = append(s.floor.Blocklist, s.project)
	}
	s.floor.NeverWrite = append(s.floor.NeverWrite, pathDirs(os.Getenv("PATH"), s.project)...)
	s.floor.NeverWrite = append(s.floor.NeverWrite, gitFloor(s.project)...)
	s.floor.Blocklist = append(s.floor.Blocklist, opts.ExtraFloor.Blocklist...)
	s.floor.NeverWrite = append(s.floor.NeverWrite, opts.ExtraFloor.NeverWrite...)
	// blinker, whose policy is named, has no API key of its own to keep
	// from the command: its policy may pass the user's.
	s.floor.PassAgentKeys = opts.Named != nil
	if err := s.Reload(); err != nil {
		s.Close()
		return nil, err
	}
	return s, nil
}

// pathDirs are the directories in a $PATH value, for the floor: a program
// written there runs outside the sandbox the next time the user types its
// name. Only absolute entries, with and without their symlinks resolved;
// not those in the project or holding it, which the agent works in
// anyway, nor any a rule could not name.
func pathDirs(path, project string) []string {
	var out []string
	for _, e := range strings.Split(path, ":") {
		if !filepath.IsAbs(e) {
			continue
		}
		e = filepath.Clean(e)
		dirs := []string{e}
		if real, err := filepath.EvalSymlinks(e); err == nil && real != e {
			dirs = append(dirs, real)
		}
		for _, d := range dirs {
			if !policy.Under(d, project) && !policy.Under(project, d) && !policy.IsGlob(d) && policy.ValidatePath(d) == nil {
				out = append(out, d)
			}
		}
	}
	return out
}

// gitFloor is what of the project's git repositories runs outside the
// sandbox the next time the user runs git there: their configuration and
// hooks, which no diff or review shows. That is the project's repository
// (or the one holding it, if the project is a directory inside one), its
// submodules' and worktrees', and any other repository in the project.
// Where the project's .git is a file (a worktree's or submodule's), the
// file is protected too, so it cannot be pointed at a repository of the
// command's making.
func gitFloor(project string) []string {
	if policy.IsGlob(project) || policy.ValidatePath(project) != nil {
		return nil // Compile refuses such a project anyway
	}
	out := []string{
		filepath.Join(project, "**", ".git", "config"),
		filepath.Join(project, "**", ".git", "hooks"),
	}
	// The repository git finds from the project: the first .git at or
	// above it. One missing at the project itself is not protected, as a
	// stand-in .git would make git (and go build) see a broken repository
	// where there is none.
	for dir := project; ; dir = filepath.Dir(dir) {
		gitDir := filepath.Join(dir, ".git")
		if fi, err := os.Lstat(gitDir); err == nil {
			if !fi.IsDir() {
				return append(out, gitDir)
			}
			return append(out,
				filepath.Join(gitDir, "config"),
				filepath.Join(gitDir, "config.worktree"),
				filepath.Join(gitDir, "hooks"),
				filepath.Join(gitDir, "worktrees", "*", "config.worktree"),
				filepath.Join(gitDir, "modules", "**", "config"),
				filepath.Join(gitDir, "modules", "**", "hooks"))
		}
		if dir == "/" {
			return out
		}
	}
}

// systemTool finds a program inside the system paths, which every sandbox
// can see.
func systemTool(name, fallback string, sys []string) string {
	p, err := exec.LookPath(name)
	if err != nil {
		return fallback
	}
	real, err := filepath.EvalSymlinks(p)
	if err != nil {
		return fallback
	}
	for _, dir := range sys {
		if policy.Under(real, dir) {
			return p
		}
	}
	return fallback
}

func randomID() string {
	b := make([]byte, 6)
	rand.Read(b) // never fails: since Go 1.24 it crashes the program instead
	return hex.EncodeToString(b)
}

// makeSessionDir makes the session's dir, with the scratch and proxy
// dirs in it.
func (s *Sandbox) makeSessionDir(dir string) error {
	if err := os.Mkdir(dir, 0o700); err != nil {
		return err
	}
	s.session = dir
	s.scratch = filepath.Join(dir, "tmp")
	s.netDir = filepath.Join(dir, "net")
	for _, d := range []string{s.scratch, s.netDir} {
		if err := os.Mkdir(d, 0o700); err != nil {
			return err
		}
	}
	s.empty = filepath.Join(dir, "empty")
	return os.WriteFile(s.empty, nil, 0o400)
}

// cleanupStale removes the dirs of sessions that ended over a week ago
// without cleaning up.
func (s *Sandbox) cleanupStale(root string) {
	entries, err := os.ReadDir(root)
	if err != nil {
		s.logf("cleaning up old sessions: %v", err)
		return
	}
	for _, e := range entries {
		if !strings.HasPrefix(e.Name(), sessionPrefix) || !e.IsDir() {
			continue
		}
		fi, err := e.Info()
		if err != nil || time.Since(fi.ModTime()) <= 7*24*time.Hour {
			continue
		}
		if st, ok := fi.Sys().(*syscall.Stat_t); ok && int(st.Uid) == os.Getuid() {
			s.remove(filepath.Join(root, e.Name()))
		}
	}
}

// Reload reads the project's policies again. On an error the policies in
// use stay.
func (s *Sandbox) Reload() error {
	if p := s.opts.Named; p != nil {
		c, err := policy.Compile(*p, s.floor, s.Env())
		if err != nil {
			return err
		}
		pol, err := policy.Bind(c, c)
		if err != nil {
			return err
		}
		s.mu.Lock()
		s.pol = pol
		s.mu.Unlock()
		return nil
	}
	l, err := s.Store().Load(s.project)
	if err != nil {
		return err
	}
	pol, tools, err := s.compile(l)
	if err != nil {
		return err
	}
	s.mu.Lock()
	s.loaded, s.pol, s.tools = l, pol, tools
	s.mu.Unlock()
	return nil
}

// Store is where the agent's policies are read and written.
func (s *Sandbox) Store() policy.Store {
	return policy.Store{
		ConfigDir: s.opts.ConfigDir,
		Dirs:      policy.NamedDirs(s.opts.ConfigDir, policy.OutsideProject(slices.Clone(s.opts.DataDirs), s.project)),
		Builtin:   policies.FS,
	}
}

// Check compiles policies as Reload would, without using them, and
// returns their rules that never apply.
func (s *Sandbox) Check(l *policy.Loaded) ([]policy.Warning, error) {
	pol, _, err := s.compile(l) // only whether they compile matters here
	if err != nil {
		return nil, err
	}
	return Lint(pol), nil
}

// Lint is the rules of the agent's policies that never apply, each once:
// one both include warns in each.
func Lint(pol policy.Pair) []policy.Warning {
	out := pol.Safe.Lint()
	for _, w := range pol.Unsafe.Lint() {
		if !slices.ContainsFunc(out, func(o policy.Warning) bool { return o.Text == w.Text }) {
			out = append(out, w)
		}
	}
	return out
}

func (s *Sandbox) compile(l *policy.Loaded) (policy.Pair, []tooldef.Def, error) {
	pol, err := l.Compile(s.floor, s.Env())
	if err != nil {
		return policy.Pair{}, nil, err
	}
	tools, err := tooldef.Load(filepath.Join(s.opts.ConfigDir, "tools"))
	if err != nil {
		return policy.Pair{}, nil, err
	}
	names := make([]string, len(tools))
	for i, t := range tools {
		names[i] = t.Name
	}
	access, err := policy.ToolAccess(l.Layer(policy.SafeMode), l.Layer(policy.UnsafeMode), names)
	if err != nil {
		return policy.Pair{}, nil, err
	}
	for i := range tools {
		tools[i].Access = access[i]
		if _, err := tools[i].Policy(pol); err != nil {
			return policy.Pair{}, nil, err
		}
	}
	return pol, tools, nil
}

// Tools returns the policy's tools, denied ones included.
func (s *Sandbox) Tools() []tooldef.Def {
	s.mu.Lock()
	defer s.mu.Unlock()
	return s.tools
}

// Shell is the bash that runs scripts.
func (s *Sandbox) Shell() string { return s.shell }

// Policy returns the policies in use: for blinker's named policy, the
// same one in both modes.
func (s *Sandbox) Policy() (policy.Pair, *policy.Loaded) {
	s.mu.Lock()
	defer s.mu.Unlock()
	return s.pol, s.loaded
}

// Env is where the session's policy applies.
func (s *Sandbox) Env() policy.Env {
	return policy.Env{
		Home: s.home, Project: s.project, Scratch: s.scratch,
		SystemRead: s.plat.SystemReadPaths(), BaselineWrite: s.plat.BaselineWritePaths(),
	}
}

// Project is the project directory (real path).
func (s *Sandbox) Project() string { return s.project }

// TempDir is the session's temp dir, where commands see it: $TMPDIR.
func (s *Sandbox) TempDir() string { return s.tmp }

// HostPath is the real path of a path as commands see it: they see the
// scratch dir at TempDir.
func (s *Sandbox) HostPath(p string) string {
	if s.tmp != s.scratch && policy.Under(p, s.tmp) {
		return filepath.Join(s.scratch, strings.TrimPrefix(p, s.tmp))
	}
	return p
}

// CommandPath is the inverse of HostPath: where commands see a real path.
func (s *Sandbox) CommandPath(p string) string {
	if s.tmp != s.scratch && policy.Under(p, s.scratch) {
		return filepath.Join(s.tmp, strings.TrimPrefix(p, s.scratch))
	}
	return p
}

// CanPreview says why commands cannot be previewed here (see Cmd.Preview),
// if they cannot: only Linux has the overlay filesystem they need.
func (s *Sandbox) CanPreview() error {
	switch runtime.GOOS {
	case "linux":
		return nil
	}
	return errors.New("previews need Linux's overlay filesystem")
}

// Hidden reports whether the project is a home or a system temp dir, which
// commands and file tools can neither read nor write, whatever the policy.
func (s *Sandbox) Hidden() bool { return s.hidden }

// Home is the home directory (real path).
func (s *Sandbox) Home() string { return s.home }

// Backend names the kernel mechanism in use.
func (s *Sandbox) Backend() string { return s.plat.Name() }

// Close stops the proxies and removes the session's dir.
func (s *Sandbox) Close() {
	s.mu.Lock()
	for p := range s.proxies {
		p.srv.Close()
	}
	clear(s.proxies)
	s.mu.Unlock()
	if s.root != "" {
		s.removeMountPoints(nil) // what is still waiting, if no sandbox is in use
	}
	if s.session != "" {
		s.remove(s.session)
	}
}

// remove deletes a directory blinker made. Failing leaves files behind,
// which the next start's cleanup tries again, so it is only logged.
func (s *Sandbox) remove(dir string) {
	if err := removeAll(dir); err != nil {
		s.logf("removing %s: %v", dir, err)
	}
}

// removeAll is os.RemoveAll for a directory of blinker's, which may hold
// an overlay's work directory: the kernel leaves one inside it with no
// permissions, which RemoveAll cannot go into.
func removeAll(dir string) error {
	err := os.RemoveAll(dir)
	if !errors.Is(err, fs.ErrPermission) {
		return err
	}
	// Errors here show in the second RemoveAll's.
	filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error {
		if err == nil && d.IsDir() {
			os.Chmod(p, 0o700)
		}
		return nil
	})
	return os.RemoveAll(dir)
}

func (s *Sandbox) logf(format string, args ...any) {
	if s.opts.Log != nil {
		s.opts.Log(fmt.Sprintf(format, args...))
	}
}

// newProxy starts a proxy for a command in a mode, deciding hosts by the
// policy current() returns. Close it with closeProxy.
func (s *Sandbox) newProxy(mode policy.Mode, current func() *policy.Compiled) (*modeProxy, error) {
	p := &modeProxy{dir: filepath.Join(s.netDir, mode.String()+"-"+randomID())}
	if err := os.MkdirAll(p.dir, 0o700); err != nil {
		return nil, err
	}
	l, err := s.plat.ListenProxy(p.dir)
	if err != nil {
		return nil, fmt.Errorf("starting the %s-mode proxy: %w", mode, err)
	}
	p.addr = l.Addr()
	p.srv = &proxy.Server{
		Ports:        s.opts.ProxyPorts,
		AllowPrivate: s.opts.AllowPrivate,
		Log:          func(line string) { s.logf("proxy %s: %s", mode, line) },
		Check: func(ctx context.Context, host string) (bool, string) {
			if s.checkDomain(ctx, current(), mode, host) {
				return true, ""
			}
			s.mu.Lock()
			p.denied = append(p.denied, host)
			s.mu.Unlock()
			if s.opts.Named != nil {
				return false, fmt.Sprintf("blinker: the policy does not let the command connect to %q.", host)
			}
			if mode == policy.SafeMode {
				return false, fmt.Sprintf("domain %q is not allowed in safe mode, which never asks. Use Unsafe_Bash to ask the user, or the user can allow it with a host rule in the project's safe policy.", host)
			}
			return false, fmt.Sprintf("domain %q was denied. Do not retry it unless the user asks.", host)
		},
	}
	s.mu.Lock()
	s.proxies[p] = true
	s.mu.Unlock()
	go p.srv.Serve(l)
	return p, nil
}

// closeProxy stops a command's proxy.
func (s *Sandbox) closeProxy(p *modeProxy) {
	p.srv.Close()
	s.remove(p.dir) // its socket, if any; nothing else is there
	s.mu.Lock()
	delete(s.proxies, p)
	s.mu.Unlock()
}

// SetAskDomain sets how unsafe commands ask the user about domains, as
// the policy's "host ask" rules say to.
func (s *Sandbox) SetAskDomain(ask func(ctx context.Context, host string) DomainAnswer) {
	s.mu.Lock()
	s.opts.AskDomain = ask
	s.mu.Unlock()
}

func (s *Sandbox) checkDomain(ctx context.Context, c *policy.Compiled, mode policy.Mode, host string) bool {
	switch c.Net(host) {
	case policy.Allow:
		return true
	case policy.Deny:
		return false
	}
	if mode == policy.SafeMode {
		return false // safe mode never asks
	}
	// Ask, on behalf of the unsafe command running now.
	s.mu.Lock()
	active, ask := s.active, s.opts.AskDomain
	if active == nil || ask == nil {
		s.mu.Unlock()
		return false
	}
	if s.allowed[host] {
		s.mu.Unlock()
		return true
	}
	q := active.questions[host]
	if q == nil {
		q = &question{}
		active.questions[host] = q
	}
	s.mu.Unlock()
	// Concurrent connections to one host share one question, and its
	// answer holds for the rest of the command either way.
	q.once.Do(func() {
		switch ask(active.ctx, host) {
		case DomainAllowOnce:
			q.ok = true
		case DomainAllowSession:
			q.ok = true
			s.mu.Lock()
			if s.allowed == nil {
				s.allowed = map[string]bool{}
			}
			s.allowed[host] = true
			s.mu.Unlock()
		case DomainAllowAlways:
			q.ok = true
			s.persistDomain(policy.NetRule{Host: host, Access: policy.Allow})
		}
	})
	return q.ok
}

func (s *Sandbox) persistDomain(rule policy.NetRule) {
	rule.Note = "Added when the user allowed it, " + time.Now().Format("2006-01-02")
	if _, err := s.Store().AppendNetRule(s.project, rule); err != nil {
		s.logf("saving the rule for %s: %v", rule.Host, err)
		return
	}
	if err := s.Reload(); err != nil {
		s.logf("reloading the policy: %v", err)
	}
}

// Cmd is a command to run in the sandbox.
type Cmd struct {
	Mode    policy.Mode
	Argv    []string
	Stdin   io.Reader
	Stdout  io.Writer
	Stderr  io.Writer
	Timeout time.Duration // 0 for none
	// Network gives the command the mode's network access, if the policy
	// allows any. Without it, the command has none.
	Network bool
	// Policy, if set, replaces the session's policy for the command: a
	// tool's, with the tool's own rules added (see tooldef.Def.Policy).
	Policy *policy.Compiled
	// Model is the model the agent runs, for $AI_MODEL; see modelEnv.
	Model string
	// Terminal, if set, is the command's stdin, stdout and stderr, and its
	// controlling terminal: a pty's slave (see internal/pty), never the
	// user's terminal, which a command could type into (TIOCSTI). Run
	// closes it once the command has started, so the pty's master sees the
	// end when the command's processes are gone. Stdin, Stdout and Stderr
	// must be nil.
	Terminal *os.File
	// Preview runs the command without its writes reaching the host, nor
	// the network: what it wrote is kept in Result.Preview, for the caller
	// to show, apply or discard. Only Linux has it.
	Preview bool
	// helper, set only by Apply, is a hidden subcommand to run in place of
	// Argv (see RunHidden).
	helper string
	// create, set only by WriteFile, is the highest missing directory (or
	// the file) of the path it writes, which the command may create: its
	// parent must be writable for that, which the policy's mounts cannot
	// always give (see WriteFile).
	create string
}

// Result is how a command ended.
type Result struct {
	ExitCode    int
	TimedOut    bool
	DeniedHosts []string // hosts the proxy refused for the command
	// Preview is what a command run with Cmd.Preview wrote. The caller
	// discards it, or it lasts until the session ends.
	Preview *Preview
}

// Run runs a command in the sandbox. An error means it could not run, or
// was canceled; a failing command is a nonzero ExitCode.
func (s *Sandbox) Run(ctx context.Context, c Cmd) (Result, error) {
	if !c.Preview {
		return s.run(ctx, c, nil)
	}
	if c.create != "" {
		return Result{}, errors.New("WriteFile cannot be previewed")
	}
	if err := s.CanPreview(); err != nil {
		return Result{}, err
	}
	p, err := newPreview(s.session)
	if err != nil {
		return Result{}, err
	}
	p.mode, p.policy = c.Mode, c.Policy
	res, err := s.run(ctx, c, p)
	if err == nil {
		err = p.read() // once the command is gone, so nothing changes meanwhile
	}
	if err != nil {
		if err := p.Discard(); err != nil {
			s.logf("discarding a preview: %v", err)
		}
		return res, err
	}
	res.Preview = p
	return res, nil
}

// run is Run, with the command's writes kept in preview if it is set.
func (s *Sandbox) run(ctx context.Context, c Cmd, preview *Preview) (Result, error) {
	var res Result
	if c.Terminal != nil {
		// Closed once the command has its own copy, or now if it never
		// will; a second close only reports that it is closed.
		defer c.Terminal.Close()
		if c.Stdin != nil || c.Stdout != nil || c.Stderr != nil {
			return res, errors.New("a command with a terminal has no other stdin, stdout or stderr")
		}
		if err := notOwnTerminal(c.Terminal); err != nil {
			return res, err
		}
	}
	pol, _ := s.Policy()
	compiled := pol.In(c.Mode)
	if c.Policy != nil {
		compiled = c.Policy
	}
	network := c.Network && compiled.NeedsNetwork() && preview == nil
	asksDomains := c.Mode == policy.UnsafeMode && network
	if asksDomains {
		s.unsafeMu.Lock()
		defer s.unsafeMu.Unlock()
	}
	runCtx := ctx
	var cancel context.CancelFunc = func() {}
	if c.Timeout > 0 {
		runCtx, cancel = context.WithTimeout(ctx, c.Timeout)
	}
	defer cancel()
	if asksDomains {
		s.mu.Lock()
		s.active = &activeCommand{ctx: runCtx, questions: map[string]*question{}}
		s.mu.Unlock()
		defer func() {
			s.mu.Lock()
			s.active = nil
			s.mu.Unlock()
		}()
	}

	var p *modeProxy
	if network {
		// A proxy of its own, deciding by the command's policy if it has
		// one, or else by the session's as it is when asked.
		current := func() *policy.Compiled { return compiled }
		if c.Policy == nil {
			current = func() *policy.Compiled {
				pol, _ := s.Policy()
				return pol.In(c.Mode)
			}
		}
		var err error
		if p, err = s.newProxy(c.Mode, current); err != nil {
			return res, err
		}
		defer s.closeProxy(p)
	}
	if err := makeDirs(compiled); err != nil {
		return res, err
	}
	// Held until the command ends, from before Wrap sees what exists, so
	// no mount point it mounts is removed meanwhile (see removeMountPoints).
	var made []string
	defer func() { s.removeMountPoints(made) }() // after the unlock below
	unlock, err := s.lockMounts(syscall.LOCK_SH)
	if err != nil {
		return res, fmt.Errorf("locking the mount points: %w", err)
	}
	defer unlock()
	w := wrap{Policy: compiled, Mode: c.Mode, Argv: c.Argv, Create: c.create, Empty: s.empty, Preview: preview, Helper: c.helper}
	if c.Terminal != nil {
		w.Terminal = c.Terminal.Name()
	}
	if p != nil {
		w.Proxy = p.addr
	}
	argv, platEnv, made, err := s.plat.Wrap(w)
	if err != nil {
		return res, err
	}

	cmd := exec.CommandContext(runCtx, argv[0], argv[1:]...)
	cmd.Env = append(environ(compiled, c.Mode, s.tmp, s.opts.Named == nil, c.Terminal != nil), platEnv...)
	cmd.Env = append(cmd.Env, modelEnv(c.Model)...)
	cmd.Stdin, cmd.Stdout, cmd.Stderr = c.Stdin, c.Stdout, c.Stderr
	cmd.Dir = s.project
	// A new session, so no controlling terminal: /dev/tty cannot reach the
	// user's terminal to draw a fake prompt or inject keystrokes (TIOCSTI).
	// bwrap does this too; sandbox-exec does not. The command leads its
	// own process group, so Cancel can kill all of it.
	cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
	if c.Terminal != nil {
		// Its own pty, as its controlling terminal (its fd 0), so /dev/tty,
		// job control and resizing work, on a terminal that is not the
		// user's.
		cmd.Stdin, cmd.Stdout, cmd.Stderr = c.Terminal, c.Terminal, c.Terminal
		cmd.SysProcAttr.Setctty, cmd.SysProcAttr.Ctty = true, 0
	}
	cmd.Cancel = func() error { return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) }
	cmd.WaitDelay = 3 * time.Second
	if err = cmd.Start(); err == nil {
		if c.Terminal != nil {
			c.Terminal.Close() // see Cmd.Terminal; the deferred close is then a no-op
		}
		err = cmd.Wait()
	}

	if p != nil {
		s.mu.Lock()
		res.DeniedHosts = slices.Clone(p.denied)
		s.mu.Unlock()
	}

	switch {
	case ctx.Err() != nil:
		return res, ctx.Err()
	case runCtx.Err() != nil:
		res.TimedOut, res.ExitCode = true, -1
		return res, nil
	}
	if ee := (*exec.ExitError)(nil); errors.As(err, &ee) {
		res.ExitCode = exitCode(ee)
		return res, nil
	}
	if err != nil && !errors.Is(err, exec.ErrWaitDelay) {
		return res, err
	}
	return res, nil
}

// exitCode is how a command ended: its exit status, or 128 plus the signal
// that killed it, as shells report it.
func exitCode(ee *exec.ExitError) int {
	if ws, ok := ee.Sys().(syscall.WaitStatus); ok && ws.Signaled() {
		return 128 + int(ws.Signal())
	}
	return ee.ExitCode()
}

// notOwnTerminal fails if f is the terminal blinker itself runs on, which
// a command must never get (see Cmd.Terminal).
func notOwnTerminal(f *os.File) error {
	fi, err := f.Stat()
	if err != nil {
		return err
	}
	for _, own := range []*os.File{os.Stdin, os.Stdout, os.Stderr} {
		if ofi, err := own.Stat(); err == nil && os.SameFile(fi, ofi) {
			return errors.New("a command's terminal must be a pty of its own, not blinker's")
		}
	}
	return nil
}

// terminalEnv describes the terminal a command with a pty gets: blinker's
// emulator (see cmd/blinker), not the user's terminal, so the user's TERM
// would promise features it lacks.
var terminalEnv = []string{"TERM=xterm-256color", "COLORTERM=truecolor"}

// agentEnv is set for every command, over the user's own values: it says
// an agent is running the command (AGENT and AI_AGENT, the emerging
// conventions, name the agent), and keeps tools from coloring output or
// waiting for a terminal that is not there.
var agentEnv = []string{
	"AGENT=blinker",
	"AI_AGENT=blinker",
	"NO_COLOR=1",
	"PAGER=cat",
	"GIT_PAGER=cat",
	"MANPAGER=cat",
	"GIT_TERMINAL_PROMPT=0", // fail rather than ask for a password
	"GIT_EDITOR=true",       // keep the default message; a commit without -m aborts
	"PYTHONUNBUFFERED=1",    // output in order, and not lost on a timeout
}

// modelEnv is AI_MODEL for a model's name, unless the name is empty or
// unusual. The name may come from the server's list of models, and a
// command may put it in a commit message or a file, so it must be one
// plain word: printable ASCII, no spaces, at most 200 bytes.
func modelEnv(model string) []string {
	if model == "" || len(model) > 200 {
		return nil
	}
	for i := range len(model) {
		if model[i] <= ' ' || model[i] > '~' {
			return nil
		}
	}
	return []string{"AI_MODEL=" + model}
}

// environ is the environment a sandboxed command gets, before the
// platform's additions: the variables of blinker's own that the policy
// passes in the mode, then blinker's own settings, with agentEnv if agent
// is set and terminalEnv if terminal is. tmp is the session's temp dir, as
// commands see it.
func environ(c *policy.Compiled, mode policy.Mode, tmp string, agent, terminal bool) []string {
	set := map[string]bool{"AI_MODEL": true, "TMPDIR": true} // see modelEnv
	own := []string{"TMPDIR=" + tmp}
	if agent {
		own = append(slices.Concat(agentEnv, own), "BLINKER_MODE="+mode.String())
	}
	if terminal {
		own = slices.Concat(own, terminalEnv)
	}
	for _, kv := range own {
		name, _, _ := strings.Cut(kv, "=")
		set[name] = true
	}
	var env []string
	for _, kv := range c.EnvValues() {
		name, _, _ := strings.Cut(kv, "=")
		if !set[name] { // blinker's own come first
			set[name] = true
			env = append(env, kv)
		}
	}
	for _, kv := range os.Environ() {
		name, _, _ := strings.Cut(kv, "=")
		if c.EnvAllowed(name) && !set[name] {
			env = append(env, kv)
		}
	}
	return append(env, own...)
}

// removeMountPoints removes the empty files and directories sandboxes
// made on the host as mount points, as they were missing before (see
// guard), once no sandbox is in use: Linux unmounts a mount point removed
// on the host in every sandbox it is mounted in, which would leave what it
// blocks open to a command still running. Every command holds the mount
// lock shared from before its sandbox is planned until it ends, and
// removing takes it exclusively. Until then the paths wait in a list in
// the scratch root, for whichever session finds the sandboxes idle. Each
// was read-only to the commands, so one that is no longer empty is not a
// sandbox's, and stays.
func (s *Sandbox) removeMountPoints(paths []string) {
	list := filepath.Join(s.root, "mount-points")
	if len(paths) > 0 {
		unlock, err := s.lockMounts(syscall.LOCK_SH)
		if err != nil {
			s.logf("listing mount points to remove: %v", err)
			return
		}
		err = appendFile(list, strings.Join(paths, "\x00")+"\x00")
		unlock()
		if err != nil {
			s.logf("listing mount points to remove: %v", err)
			return
		}
	}
	unlock, err := s.lockMounts(syscall.LOCK_EX | syscall.LOCK_NB)
	if errors.Is(err, syscall.EWOULDBLOCK) {
		return // a sandbox is in use; the last to end removes them
	}
	if err != nil {
		s.logf("removing mount points: %v", err)
		return
	}
	defer unlock()
	data, err := os.ReadFile(list)
	if errors.Is(err, fs.ErrNotExist) {
		return
	}
	if err != nil {
		s.logf("removing mount points: %v", err)
		return
	}
	paths = strings.Split(strings.TrimSuffix(string(data), "\x00"), "\x00")
	for i := len(paths) - 1; i >= 0; i-- { // the deepest of each command's first
		p := paths[i]
		fi, err := os.Lstat(p)
		if err != nil || !filepath.IsAbs(p) {
			continue // gone already
		}
		if !fi.IsDir() && (!fi.Mode().IsRegular() || fi.Size() != 0) {
			s.logf("not removing mount point %s: it is no longer an empty file", p)
			continue
		}
		if err := os.Remove(p); err != nil && !errors.Is(err, fs.ErrNotExist) {
			s.logf("removing mount point %s: %v", p, err)
		}
	}
	if err := os.Remove(list); err != nil {
		s.logf("removing mount points: %v", err)
	}
}

// lockMounts takes the mount lock (see removeMountPoints) as how says, a
// flock operation, and returns what releases it.
func (s *Sandbox) lockMounts(how int) (unlock func(), err error) {
	f, err := os.OpenFile(filepath.Join(s.root, "mounts.lock"), os.O_RDWR|os.O_CREATE|syscall.O_NOFOLLOW, 0o600)
	if err != nil {
		return nil, err
	}
	if err := syscall.Flock(int(f.Fd()), how); err != nil {
		f.Close()
		return nil, err
	}
	return func() { f.Close() }, nil // closing it releases the lock
}

// appendFile appends text to a file of the user's own.
func appendFile(path, text string) error {
	f, err := os.OpenFile(path, os.O_WRONLY|os.O_APPEND|os.O_CREATE|syscall.O_NOFOLLOW, 0o600)
	if err != nil {
		return err
	}
	if _, err := f.WriteString(text); err != nil {
		f.Close()
		return err
	}
	return f.Close()
}

// ErrTooLarge reports a file over the size ReadFile was given.
var ErrTooLarge = errors.New("file too large")

// ReadFile reads a file inside the sandbox, so the kernel checks the access
// at the moment of the read, through whatever symlinks are there then.
func (s *Sandbox) ReadFile(ctx context.Context, mode policy.Mode, path string, maxSize int64) ([]byte, error) {
	stdout := limitedBuffer{max: maxSize}
	var stderr bytes.Buffer
	res, err := s.Run(ctx, Cmd{
		Mode:    mode,
		Argv:    []string{s.cat, "--", s.CommandPath(path)},
		Stdout:  &stdout,
		Stderr:  &stderr,
		Timeout: time.Minute,
	})
	if err != nil {
		return nil, err
	}
	if stdout.over {
		return nil, ErrTooLarge
	}
	if res.ExitCode != 0 {
		return nil, fileError(stderr.String(), path, s.CommandPath(path))
	}
	return stdout.buf.Bytes(), nil
}

// WriteFile writes a file inside the sandbox, creating its directory.
//
// A file that does not exist yet can be created wherever the policy lets
// the mode write that path, even inside a directory the mode cannot write
// (say, a new .md file where only *.md is writable). Bash commands cannot do
// that on Linux; see Cmd.create.
func (s *Sandbox) WriteFile(ctx context.Context, mode policy.Mode, path string, data []byte) error {
	if policy.RealPath(path) != path {
		// The policy judged this path; through a symlink the write would
		// land somewhere else, and the widened mount below would follow it.
		return &FileError{Path: path, Msg: "the path goes through a symlink; write its real path"}
	}
	create := ""
	if _, err := os.Lstat(path); errors.Is(err, fs.ErrNotExist) && !policy.Under(path, s.scratch) {
		// Checked here too, not only by the caller: this is the one place
		// the sandbox widens a mount, so it must never do so for a path the
		// policy does not let the mode write.
		if pol, _ := s.Policy(); !pol.In(mode).Access(path).Write {
			return &FileError{Path: path, Msg: "the policy does not allow writing it in " + mode.String() + " mode"}
		}
		top, parent, err := missingTop(path)
		if err != nil {
			return err
		}
		if !parent.IsDir() {
			return &FileError{Path: path, Msg: "there is no directory to create it in"}
		}
		create = top
	}
	var stderr bytes.Buffer
	res, err := s.Run(ctx, Cmd{
		create:  create,
		Mode:    mode,
		Argv:    []string{s.sh, "-c", `mkdir -p -- "$(dirname -- "$1")" && cat > "$1"`, "sh", s.CommandPath(path)},
		Stdin:   bytes.NewReader(data),
		Stdout:  io.Discard,
		Stderr:  &stderr,
		Timeout: time.Minute,
	})
	if err != nil {
		return err
	}
	if res.ExitCode != 0 {
		return fileError(stderr.String(), path, s.CommandPath(path))
	}
	return nil
}

// fileError turns cat's or sh's complaint about path, which they saw as
// seen, into an error.
func fileError(stderr, path, seen string) error {
	msg := strings.TrimSpace(stderr)
	if i := strings.LastIndex(msg, "\n"); i >= 0 {
		msg = msg[i+1:]
	}
	if msg == "" {
		msg = "failed"
	}
	if _, after, ok := strings.Cut(msg, seen); ok {
		msg = strings.TrimLeft(after, ": ")
	}
	return &FileError{Path: path, Msg: msg}
}

// FileError is a file operation the sandboxed program reported failing.
type FileError struct {
	Path, Msg string
}

func (e *FileError) Error() string { return e.Path + ": " + e.Msg }

// limitedBuffer keeps up to max bytes, and fails a write past them. The
// buffer is a field, not embedded, so it has no ReadFrom: io.Copy, which
// os/exec uses, would call that instead of Write and skip the limit.
type limitedBuffer struct {
	buf  bytes.Buffer
	max  int64
	over bool
}

func (b *limitedBuffer) Write(p []byte) (int, error) {
	if b.max > 0 && int64(b.buf.Len()+len(p)) > b.max {
		b.over = true
		return 0, ErrTooLarge
	}
	return b.buf.Write(p)
}