Download
837 lines 24.9 KiBGoModified 2026-10-31 raw
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837
package policy

import (
	"fmt"
	"os"
	"path"
	"path/filepath"
	"slices"
	"strings"
)

// Env is where a policy applies. Every path is absolute with symlinks
// resolved.
type Env struct {
	Home    string
	Project string // the directory blinker runs in
	// Scratch is the session's temp dir: readable and writable in both
	// modes, whatever the rules and the floor say.
	Scratch string
	// SystemRead is readable in every mode unless a rule says otherwise:
	// enough of the OS to run programs. BaselineWrite is writable in every
	// mode unless a rule says otherwise. The sandbox's platform gives both.
	SystemRead    []string
	BaselineWrite []string
}

// Floor is the access no rule can grant: caps applied after every rule.
type Floor struct {
	Blocklist  []string // never readable (or writable), in any mode
	NeverWrite []string // never writable, in any mode
	// PassAgentKeys lets rules pass the API key variables blinker-agent
	// falls back on (see agentKeys) to commands. Only blinker, the sandbox
	// command, sets it: it has no key of its own, and a policy passes the
	// user's to the agent it runs.
	PassAgentKeys bool
}

// DefaultFloor is the built-in floor: credential stores, files that change
// what runs outside the sandbox later (shell and editor startup files,
// the usual bin dirs, what runs at login, git's configuration), and
// blinker's own directories (config, policies, history, installed
// policies). The sandbox adds the directories in $PATH, and the project's
// git configuration and hooks (gitFloor).
func DefaultFloor(ownDirs ...string) Floor {
	f := Floor{
		Blocklist: []string{
			"~/.ssh",
			"~/.gnupg",
			"~/.aws",
			"~/.azure",
			"~/.kube",
			"~/.docker/config.json",
			"~/.config/gh",
			"~/.config/gcloud",
			"~/.git-credentials",
			"~/.netrc",
			"~/.pypirc",
			"~/.npmrc",
			"~/.password-store",
			"~/Library/Keychains",
			"~/**/*.pem",
			"~/**/*.key",
			"~/**/id_rsa*",
			"~/**/id_ed25519*",
			"~/**/id_ecdsa*",
			"~/**/id_dsa*",
		},
		NeverWrite: []string{
			"~/.ssh",
			"~/.gnupg",
			"~/.aws",
			// What runs at login or in every session, not only when the
			// program it belongs to does: a program's own configuration
			// is its policy's to give.
			"~/.config/systemd",
			"~/.local/share/systemd",
			"~/.config/autostart",
			"~/.config/environment.d",
			"~/.config/plasma-workspace",
			"~/.config/fish",
			"~/.config/zsh",
			"~/.config/tmux",
			"~/.config/direnv",
			"~/.config/git",
			"~/.local/bin",
			"~/.cargo/bin",
			"~/go/bin",
			"~/bin",
			"~/.zshrc",
			"~/.zshenv",
			"~/.zprofile",
			"~/.zlogin",
			"~/.bashrc",
			"~/.bash_profile",
			"~/.bash_login",
			"~/.profile",
			"~/.bash_logout",
			"~/.zlogout",
			"~/.cshrc",
			"~/.tcshrc",
			"~/.login",
			"~/.logout",
			"~/.kshrc",
			"~/.mkshrc",
			"~/.inputrc",
			"~/.pam_environment",
			"~/.xprofile",
			"~/.xinitrc",
			"~/.xsession",
			"~/.xsessionrc",
			// Editors and terminal multiplexers run their own startup
			// files: blinker itself opens vimdiff.
			"~/.vimrc",
			"~/.gvimrc",
			"~/.exrc",
			"~/.vim",
			"~/.emacs",
			"~/.emacs.d",
			"~/.tmux.conf",
			"~/.screenrc",
			"~/.ipython",
			"~/Library/LaunchAgents",
			"~/.gitconfig",
		},
	}
	// The XDG base directories may have moved: the same entries there too.
	xdg := func(entries []string) []string {
		out := slices.Clone(entries)
		for _, e := range entries {
			if rest, ok := strings.CutPrefix(e, "~/.config/"); ok {
				out = append(out, "${XDG_CONFIG_HOME:-~/.config}/"+rest)
			} else if rest, ok := strings.CutPrefix(e, "~/.local/share/"); ok {
				out = append(out, "${XDG_DATA_HOME:-~/.local/share}/"+rest)
			}
		}
		return out
	}
	f.Blocklist, f.NeverWrite = xdg(f.Blocklist), xdg(f.NeverWrite)
	for _, dir := range ownDirs {
		// The API key, policies and history: never readable, so the agent
		// cannot learn the key, and never writable, so a sandboxed command
		// can never author its own grants or rewrite the record.
		if dir != "" {
			f.Blocklist = append(f.Blocklist, dir)
			f.NeverWrite = append(f.NeverWrite, dir)
		}
	}
	return f
}

// InHome is the floor's plain paths for a home (those starting with ~/),
// moved to another: home, another user's that holds the project, which
// the sandbox cannot hide whole as it does other users' homes. Not the
// patterns (~/**/*.pem): the sandbox applies those only in the project,
// which Compile gives copies of its own when it is outside ~, and every
// copy costs each command a pass over the project.
func (f Floor) InHome(home string) Floor {
	move := func(entries []string) []string {
		var out []string
		for _, e := range entries {
			if rest, ok := strings.CutPrefix(e, "~/"); ok && !IsGlob(e) {
				out = append(out, filepath.Join(home, rest))
			}
		}
		return out
	}
	return Floor{Blocklist: move(f.Blocklist), NeverWrite: move(f.NeverWrite)}
}

// envFloor names the environment variables no rule can pass to a command:
// blinker's own, agent sockets, code a shell runs before the script the
// user approved, and the proxy settings blinker sets itself.
var envFloor = []string{
	"BLINKER_*",
	"SSH_AUTH_SOCK", "SSH_AGENT_PID", "GPG_AGENT_INFO", "DBUS_SESSION_BUS_ADDRESS",
	"BASH_ENV", "ENV", "PROMPT_COMMAND", "BASH_FUNC_*",
	"LD_PRELOAD", "LD_AUDIT", "DYLD_*",
	"*_PROXY", "*_proxy",
}

// baselineEnv names the environment variables passed in every mode unless
// a rule says otherwise. Everything else is passed only if a rule allows
// it.
var baselineEnv = []string{"PATH", "HOME", "USER", "LOGNAME", "SHELL", "LANG", "LANGUAGE", "LC_*", "TERM", "COLORTERM", "TZ", "TMPDIR"}

func matchesAny(name string, globs []string) bool {
	for _, g := range globs {
		if ok, err := path.Match(g, name); err == nil && ok {
			return true
		}
	}
	return false
}

// agentKeys names the API key variables blinker-agent falls back on, which
// no rule can pass to its commands (see Floor.PassAgentKeys).
var agentKeys = []string{"ANTHROPIC_API_KEY", "OPENAI_API_KEY"}

// EnvFloored reports whether no rule can pass a variable to commands, the
// agent's API keys included.
func EnvFloored(name string) bool { return matchesAny(name, envFloor) || matchesAny(name, agentKeys) }

// envFloored is EnvFloored for this policy's floor.
func (c *Compiled) envFloored(name string) bool {
	return matchesAny(name, envFloor) || !c.passKeys && matchesAny(name, agentKeys)
}

// Layer is a policy to compile, with its name for messages.
type Layer struct {
	Name string // for messages, such as "safe policy"
	File *File
}

// ruleWhere names a rule of the layer in messages: the policy and line it
// was included from, if another policy's, or else its place in the layer.
func (l Layer) ruleWhere(from, kind string, i, line int) string {
	if from != "" {
		return from
	}
	return l.Name + " " + where(kind, i, line)
}

type rule struct {
	FileRule
	m       Matcher
	where   string   // "safe policy line 3", or "policy build line 3"
	fromEnv bool     // one directory of a "$NAME" rule
	links   []string // the symlinks followed to its real path
}

type netRule struct {
	NetRule
	where string
}

// Compiled is a stack of policies ready to answer questions about paths and
// hosts in one Env.
type Compiled struct {
	// peers are the policies, this one included, that commands may run
	// under while it is in use: a symlink any of them lets commands
	// replace must not redirect a rule (see Bind).
	peers      []*Compiled
	Env        Env
	passKeys   bool // the floor's PassAgentKeys
	rules      []rule
	net        []netRule
	env        []EnvRule
	blocklist  []Matcher
	neverWrite []Matcher
	// The floor's plain paths again, as sets: with every user's home on
	// it, the floor has hundreds, and Access, which the sandbox calls for
	// each of them, would otherwise check every one.
	blockPaths, neverWritePaths map[string]bool
}

// Compile makes a policy ready to answer questions about paths and hosts
// in an Env.
func Compile(l Layer, floor Floor, env Env) (*Compiled, error) {
	// Rule paths are globs over these, so a "*" in one would make every
	// rule under it a pattern: "." in a project p[1] would match p1.
	for _, p := range []string{env.Home, env.Project, env.Scratch} {
		if p != "" && (IsGlob(p) || ValidatePath(p) != nil) {
			return nil, fmt.Errorf("blinker cannot work with the directory %q: its path holds characters (*, ?, [, a quote or a control character) that rules would take as patterns", p)
		}
	}
	c := &Compiled{Env: env, passKeys: floor.PassAgentKeys}
	c.peers = []*Compiled{c}
	for i, r := range l.File.Files {
		at := l.ruleWhere(r.From, "file", i, r.Line)
		if IsEnvPath(r.Path) {
			if err := ValidatePath(r.Path); err != nil {
				return nil, fmt.Errorf("%s: %w", at, err)
			}
			for _, dir := range envPaths(r.Path[1:], env) {
				c.rules = append(c.rules, rule{FileRule: r, m: Matcher{Path: dir.path}, where: at, fromEnv: true, links: dir.links})
			}
			continue
		}
		m, links, err := newMatcher(r.Path, env.Home, env.Project)
		if err != nil {
			return nil, fmt.Errorf("%s: %w", at, err)
		}
		c.rules = append(c.rules, rule{FileRule: r, m: m, where: at, links: links})
	}
	for i, r := range l.File.Network {
		c.net = append(c.net, netRule{NetRule: r, where: l.ruleWhere(r.From, "host", i, r.Line)})
	}
	c.env = append([]EnvRule(nil), l.File.Env...)
	for _, list := range []struct {
		entries []string
		out     *[]Matcher
	}{{floor.Blocklist, &c.blocklist}, {floor.NeverWrite, &c.neverWrite}} {
		entries := list.entries
		for _, e := range list.entries {
			// The floor's patterns for keys anywhere in home (~/**/*.pem)
			// are walked in the project; one outside home needs its own.
			if rest, ok := strings.CutPrefix(e, "~/**/"); ok && !Under(env.Project, env.Home) {
				entries = append(entries, filepath.Join(env.Project, "**", rest))
			}
		}
		for _, e := range entries {
			if !strings.HasPrefix(e, "/") && !strings.HasPrefix(e, "~/") && !IsVarPath(e) {
				return nil, fmt.Errorf("floor: %q must be absolute, or start with ~/ or ${", e)
			}
			m, err := NewMatcher(e, env.Home, env.Project)
			if err != nil {
				return nil, fmt.Errorf("floor: %w", err)
			}
			*list.out = append(*list.out, m)
			// A credential store may be a symlink (~/.ssh to a dotfiles
			// repo, say). Accesses are judged by real path, so the floor
			// covers where it points, which NewMatcher has resolved outside
			// the project, and also the path as written, in case a command
			// replaces the link. This only ever adds denials.
			if !m.IsGlob() && !IsVarPath(e) { // a variable's path has its default in the floor too
				written := Expand(strings.TrimSuffix(e, "/**"), env.Home, env.Project)
				real := ResolveExisting(m.Path)
				if written != m.Path && !IsGlob(written) {
					*list.out = append(*list.out, Matcher{Path: written})
				}
				if real != m.Path && real != written {
					*list.out = append(*list.out, Matcher{Path: real})
				}
			}
		}
	}
	c.blockPaths, c.neverWritePaths = plainPaths(c.blocklist), plainPaths(c.neverWrite)
	if err := c.checkLinks(); err != nil {
		return nil, err
	}
	return c, nil
}

// checkLinks refuses a rule that follows a symlink a command could
// replace, under any of the peers: the link would redirect the rule at
// the next start, and a grant of /data/ref, made a link to home by a
// command that may write /data, would grant home. Not even one directory
// of a "$NAME" rule is left out instead: the rule may narrow an earlier
// one.
func (c *Compiled) checkLinks() error {
	for _, r := range c.rules {
		for _, link := range r.links {
			if c.replaceable(link) {
				return fmt.Errorf("%s: %q goes through the symlink %s, which commands may replace; write the path it leads to (%s) instead, or stop commands writing %s", r.where, r.Path, link, r.m.Path, filepath.Dir(link))
			}
		}
	}
	return nil
}

// replaceable reports whether a command under any of the peers may
// replace a symlink: write the directory it is in.
func (c *Compiled) replaceable(link string) bool {
	for _, p := range c.peers {
		if p.Access(filepath.Dir(link)).Write {
			return true
		}
	}
	return false
}

// Pair is the agent's two policies for a project: Safe for Safe_Bash and
// the file tools without asking, Unsafe for Unsafe_Bash and the file
// tools once the user approves.
type Pair struct {
	Safe, Unsafe *Compiled
}

// Bind makes a Pair of two policies, whose rules may follow no symlink
// that commands under either could replace.
func Bind(safe, unsafe *Compiled) (Pair, error) {
	peers := []*Compiled{safe, unsafe}
	if safe == unsafe {
		peers = peers[:1]
	}
	for _, c := range peers {
		c.peers = peers
	}
	for _, c := range peers {
		if err := c.checkLinks(); err != nil {
			return Pair{}, err
		}
	}
	return Pair{safe, unsafe}, nil
}

// In is the policy commands in a mode run under.
func (p Pair) In(m Mode) *Compiled {
	if m == UnsafeMode {
		return p.Unsafe
	}
	return p.Safe
}

// envPaths returns the directories a "$NAME" rule covers: each entry of
// the variable's ":"-separated value that is absolute, exists, and, with
// its symlinks resolved, has no glob characters and neither is nor
// contains home or the project. Entries inside home are fine; "/", "/home"
// or home itself would open far more than a list of tool directories
// should, and the value may come from a project's .envrc. Each entry is a
// plain path, never a pattern.
func envPaths(name string, env Env) []envPath {
	var out []envPath
	for _, e := range strings.Split(os.Getenv(name), ":") {
		real, links, ok := varDir(e, env.Home, env.Project)
		if !ok {
			continue
		}
		if _, err := os.Stat(real); err != nil {
			continue
		}
		out = append(out, envPath{real, links})
	}
	return out
}

// varDir checks a directory an environment variable names for a rule
// ("$NAME" or "${NAME:-default}"), which may have been set by a project:
// it must be absolute, and with its symlinks resolved, as the kernel will
// see it, free of glob characters, neither home nor the project, nor a
// directory holding either, nor inside the project, where a rule for it
// could undo the policy's own (a hide rule for **/.env*). It returns the
// real path and the symlinks followed to it.
func varDir(v, home, project string) (string, []string, bool) {
	if !filepath.IsAbs(v) {
		return "", nil, false // empty or relative: the current directory, wherever that is
	}
	real, links := resolveLinks(v)
	if IsGlob(real) || ValidatePath(real) != nil || Under(home, real) || Under(project, real) || Under(real, project) {
		return "", nil, false
	}
	return real, links, true
}

// envPath is one directory of a "$NAME" rule, and the symlinks followed
// to it.
type envPath struct {
	path  string
	links []string
}

// EnvAllowed reports whether commands get an environment variable: never
// one in the floor; otherwise as the last env rule matching it says, or,
// with none, if it is in the baseline.
func (c *Compiled) EnvAllowed(name string) bool {
	if c.envFloored(name) {
		return false
	}
	for i := len(c.env) - 1; i >= 0; i-- {
		if ok, err := path.Match(c.env[i].Name, name); err == nil && ok {
			return c.env[i].Access == Allow
		}
	}
	return matchesAny(name, baselineEnv)
}

// EnvValues returns the variables the rules set (a policy's "env set"),
// as NAME=VALUE, with a leading "~/" in a value meaning home. The last
// rule for a name wins.
func (c *Compiled) EnvValues() []string {
	values := map[string]string{}
	var names []string
	for _, r := range c.env {
		if r.Value == "" || c.envFloored(r.Name) {
			continue
		}
		if _, ok := values[r.Name]; !ok {
			names = append(names, r.Name)
		}
		v := r.Value
		if IsVarPath(v) {
			var err error
			if v, err = resolveVarPath(v, c.Env.Home, c.Env.Project); err != nil {
				continue // checked when the policy was read
			}
		}
		if rest, ok := strings.CutPrefix(v, "~/"); ok {
			v = filepath.Join(c.Env.Home, rest)
		}
		values[r.Name] = v
	}
	var out []string
	for _, name := range names {
		out = append(out, name+"="+values[name])
	}
	return out
}

// Access is what commands may do with a path.
type Access struct {
	Read, Write bool
	// Floor is set when the floor is what denies some access.
	Floor bool
}

// Has reports whether the access includes reading, or writing.
func (a Access) Has(write bool) bool {
	if write {
		return a.Write
	}
	return a.Read
}

func underAny(path string, dirs []string) bool {
	for _, d := range dirs {
		if Under(path, d) {
			return true
		}
	}
	return false
}

// decider returns the last rule matching path, or nil.
func (c *Compiled) decider(path string) *rule {
	for i := len(c.rules) - 1; i >= 0; i-- {
		if c.rules[i].m.Match(path) {
			return &c.rules[i]
		}
	}
	return nil
}

// Access evaluates a path (absolute, symlinks resolved; see RealPath):
// the last matching rule's access, or the baseline if no rule matches;
// then the floor. The scratch dir is always readable and writable.
func (c *Compiled) Access(path string) Access {
	if Under(path, c.Env.Scratch) {
		return Access{Read: true, Write: true}
	}
	var a Access
	if r := c.decider(path); r != nil {
		a.Read, a.Write = r.Access >= Read, r.Access == Write
	} else {
		a.Read = path == "/" || underAny(path, c.Env.SystemRead)
		a.Write = underAny(path, c.Env.BaselineWrite)
	}
	// The floor's patterns apply in the project, where the sandbox looks
	// for what they match (see sandbox.plan): searching every readable
	// cache on each command would be too slow.
	inProject := Under(path, c.Env.Project)
	if underSet(path, c.blockPaths) || inProject && matchGlobs(path, c.blocklist) {
		a.Floor = a.Floor || a.Read || a.Write
		a.Read, a.Write = false, false
	}
	if underSet(path, c.neverWritePaths) || inProject && matchGlobs(path, c.neverWrite) {
		a.Floor = a.Floor || a.Write
		a.Write = false
	}
	return a
}

// plainPaths are the paths of the matchers that are not globs.
func plainPaths(ms []Matcher) map[string]bool {
	set := map[string]bool{}
	for _, m := range ms {
		if !m.IsGlob() {
			set[m.Path] = true
		}
	}
	return set
}

// underSet reports whether Under(path, d) for some d in set, without
// checking each: d must be path, "/", or path up to one of its slashes.
func underSet(path string, set map[string]bool) bool {
	if set[path] || set["/"] {
		return true
	}
	for i := len(path) - 1; i > 0; i-- {
		if path[i] == '/' && set[path[:i]] {
			return true
		}
	}
	return false
}

// matchGlobs reports whether one of the globs among ms matches path.
func matchGlobs(path string, ms []Matcher) bool {
	for _, m := range ms {
		if m.IsGlob() && m.Match(path) {
			return true
		}
	}
	return false
}

// Decide says how a file tool may access a path: Safe if the safe policy
// lets it, Confirm if only the unsafe one does, Forbid otherwise.
func (p Pair) Decide(path string, write bool) Level {
	if p.Safe.Access(path).Has(write) {
		return Safe
	}
	if p.Unsafe.Access(path).Has(write) {
		return Confirm
	}
	return Forbid
}

// Explain says which rule decides a path's access in each policy, for
// the user and the model.
func (p Pair) Explain(path string, write bool) string {
	if p.Safe == p.Unsafe {
		return p.Safe.Explain(path, write)
	}
	return fmt.Sprintf("safe policy: %s; unsafe policy: %s → %s", p.Safe.explain(path), p.Unsafe.explain(path), p.Decide(path, write))
}

// Explain says which rule decides a path's access, for the user and the
// model.
func (c *Compiled) Explain(path string, write bool) string {
	op := "read"
	if write {
		op = "write"
	}
	verdict := "allowed"
	if !c.Access(path).Has(write) {
		verdict = "denied"
	}
	return fmt.Sprintf("%s %s: %s → %s", op, path, c.explain(path), verdict)
}

// explain says what decides a path's access: a rule, or none, and the
// floor if it blocks some access.
func (c *Compiled) explain(path string) string {
	if Under(path, c.Env.Scratch) {
		return "the session's temp dir, always open"
	}
	s := "no rule matches"
	if r := c.decider(path); r != nil {
		s = fmt.Sprintf("decided by %s (%s %q)", r.where, r.Access, r.Path)
	}
	if c.Access(path).Floor {
		s += ", but the credential floor blocks some access"
	}
	return s
}

// Net decides a host: the last matching network rule, or deny if none
// matches.
func (c *Compiled) Net(host string) NetPerm {
	if r := c.netRule(host); r != nil {
		return r.Access
	}
	return Deny
}

// DeniedByName reports whether the rule deciding host denies it by name
// (or as one of a domain's subdomains), not as "*" or for want of a rule:
// a host the policy means to keep out, such as telemetry.
func (c *Compiled) DeniedByName(host string) bool {
	r := c.netRule(host)
	return r != nil && r.Host != "*" && r.Access == Deny
}

// netRule is the last network rule matching host, or nil.
func (c *Compiled) netRule(host string) *netRule {
	for i := len(c.net) - 1; i >= 0; i-- {
		if HostMatches(host, c.net[i].Host) {
			return &c.net[i]
		}
	}
	return nil
}

// NeedsNetwork reports whether commands may reach the network at all,
// allowed or once asked.
func (c *Compiled) NeedsNetwork() bool {
	for _, r := range c.net {
		if r.Access != Deny {
			return true
		}
	}
	return false
}

// AllowedHosts lists the host patterns rules allow without asking (a
// later rule may still deny some hosts they match).
func (c *Compiled) AllowedHosts() []string {
	var out []string
	for _, r := range c.net {
		if r.Access == Allow {
			out = append(out, r.Host)
		}
	}
	return out
}

// Literals returns the plain (non-glob) paths of the rules and the floor:
// places where access can change.
func (c *Compiled) Literals() []string {
	var out []string
	for _, r := range c.rules {
		if !r.m.IsGlob() {
			out = append(out, r.m.Path)
		}
	}
	for _, list := range [][]Matcher{c.blocklist, c.neverWrite} {
		for _, m := range list {
			if !m.IsGlob() {
				out = append(out, m.Path)
			}
		}
	}
	return out
}

// NamedDir reports whether a rule names p, a plain path, as a directory
// (see Matcher.Dir).
func (c *Compiled) NamedDir(p string) bool {
	for _, r := range c.rules {
		if r.m.Dir && !r.m.IsGlob() && r.m.Path == p {
			return true
		}
	}
	return false
}

// Links returns the symlinks the rules followed to their real paths, which
// the sandbox shows where it would otherwise hide them (a link in home to
// a dotfiles repo).
func (c *Compiled) Links() []string {
	var out []string
	for _, r := range c.rules {
		out = append(out, r.links...)
	}
	slices.Sort(out)
	return slices.Compact(out)
}

// RuleGlobs returns the rules' globs.
func (c *Compiled) RuleGlobs() []Matcher {
	var out []Matcher
	for _, r := range c.rules {
		if r.m.IsGlob() {
			out = append(out, r.m)
		}
	}
	return out
}

// GrantGlobs returns the globs of the rules that let commands read what
// they match.
func (c *Compiled) GrantGlobs() []Matcher {
	var out []Matcher
	for _, r := range c.rules {
		if r.m.IsGlob() && r.Access >= Read {
			out = append(out, r.m)
		}
	}
	return out
}

// FloorGlobs returns the floor's globs.
func (c *Compiled) FloorGlobs() []Matcher {
	var out []Matcher
	for _, list := range [][]Matcher{c.blocklist, c.neverWrite} {
		for _, m := range list {
			if m.IsGlob() {
				out = append(out, m)
			}
		}
	}
	return out
}

// FloorMatchers returns the floor's blocklist and never-write matchers.
func (c *Compiled) FloorMatchers() (blocklist, neverWrite []Matcher) {
	return c.blocklist, c.neverWrite
}

// A Warning is a rule that can never apply, because a later one covers it.
type Warning struct {
	Text string
	// key is the two rules by file and pattern, not by line, which
	// changes when lines are added above them.
	key [4]string
}

// Same reports whether two warnings are about the same rules, wherever
// they are in their files.
func (w Warning) Same(o Warning) bool { return w.key == o.key }

// Lint finds rules that can never decide anything, because a later rule
// covers everything they match. With the last match winning, a broad rule
// added after a narrow one silently undoes it; this says so.
func (c *Compiled) Lint() []Warning {
	var out []Warning
	for i, r := range c.rules {
		if r.fromEnv {
			continue // its other directories may still apply
		}
		for j := len(c.rules) - 1; j > i; j-- {
			if later := c.rules[j]; later.m.Covers(r.m) {
				if later.Access != r.Access {
					out = append(out, Warning{
						Text: fmt.Sprintf("%s (%q) never applies: %s (%q) comes later and covers everything it matches", r.where, r.Path, later.where, later.Path),
						key:  [4]string{whereFile(r.where), "file " + r.Path, whereFile(later.where), later.Path},
					})
				}
				break
			}
		}
	}
	for i, r := range c.net {
		for j := len(c.net) - 1; j > i; j-- {
			if later := c.net[j]; hostCovers(later.Host, r.Host) {
				if later.Access != r.Access {
					out = append(out, Warning{
						Text: fmt.Sprintf("%s (%q) never applies: %s (%q) comes later and covers it", r.where, r.Host, later.where, later.Host),
						key:  [4]string{whereFile(r.where), "host " + r.Host, whereFile(later.where), later.Host},
					})
				}
				break
			}
		}
	}
	return out
}

// whereFile is the file a rule's where names: "policy build" for "policy
// build line 3".
func whereFile(where string) string {
	if i := strings.LastIndex(where, " line "); i >= 0 {
		return where[:i]
	}
	return where
}

// hostCovers reports whether host pattern later matches every host
// pattern earlier does.
func hostCovers(later, earlier string) bool {
	if later == "*" || later == earlier {
		return true
	}
	base, ok := strings.CutPrefix(later, "*.")
	return ok && strings.HasSuffix(earlier, "."+base)
}