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)
}
|