agent

package
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 18, 2026 License: MIT Imports: 31 Imported by: 0

Documentation

Overview

Package agent hosts the fleet-agent daemon: configuration, the gRPC server — mutually authenticated unless the operator has deliberately said otherwise, see TLSConfig.Enabled — and the lifecycle every M1 service plugs into.

Registering a service

A service package registers itself from an init function and is then hosted by every daemon that imports it:

package exec

func init() {
	agent.Register("exec", func(d agent.Deps) (agent.Service, error) {
		return &Service{jail: d.Jail, log: d.Log.With("service", "exec")}, nil
	})
}

// Service implements agent.Service.
func (s *Service) Register(r grpc.ServiceRegistrar) {
	sandboxdv1.RegisterExecServiceServer(r, s)
}

The daemon constructs every registered factory once, in name order, before it starts listening, and fails to start if any of them returns an error. The only wiring a new service package needs outside itself is one blank import in internal/cli/fleetagent/services.go.

What a service gets

Deps carries the config, the path jail, a logger, the shared Status, and build metadata. PrincipalFromContext returns the Principal the daemon resolved for any RPC it served — the client certificate's common name, or, on an agent serving without mTLS, the peer address named as unauthenticated — which is what an audit record is keyed on, together with what established it.

Shutdown

A Service may also implement Shutdowner to participate in graceful shutdown. The contract is deliberately narrow: shutdown means "stop serving and flush your own state", never "kill the work you started". Background processes supervised by the agent outlive the daemon by design — an agent upgrade must not take down every dev server in the fleet — so the daemon never signals a child process, and neither should a Shutdowner.

Index

Constants

View Source
const ConfigFileName = "agent.yaml"

ConfigFileName is the agent config's basename in every location it is searched for.

View Source
const DefaultClientOU = "sandboxd-control"

DefaultClientOU is the organizational unit an incoming client certificate must carry. It matches ca.ProfileControl's OU: a leaf issued to another agent carries "sandboxd-agent" and is refused.

It keeps its pre-rebrand name because it is matched against certificates already issued to enrolled agents; see ca.Profile.OrganizationalUnit.

View Source
const DefaultDrainTimeout = 30 * time.Second

DefaultDrainTimeout bounds how long shutdown waits for in-flight RPCs.

It is generous because the calls it is waiting on are real work — a build streaming output, a large file transfer — and cutting those off costs the caller the whole call. It is bounded because a stuck stream must not stop the daemon exiting: systemd's own TimeoutStopSec is what would kill it next, far less politely.

View Source
const DefaultListen = "0.0.0.0:8722"

DefaultListen is the address the agent serves gRPC on when the config names none. Port 8722 is what the MCP server's registry records by default.

View Source
const EnvConfig = "FLEET_AGENT_CONFIG"

EnvConfig names an environment variable holding an explicit config path, for a caller that wants to pin one without passing --config on every invocation: a shell profile, a CI job, a container image.

The service units this repository installs do not use it — all three pass `serve --config <path>` in argv (see UnitParams.Arguments) — so an installed daemon does not depend on it.

View Source
const LegacyEnvConfig = "SANDBOXD_AGENT_CONFIG"

LegacyEnvConfig is what EnvConfig was called before the fleet rebrand. It is honoured when it is the only one set, because an operator who exported it in a profile, a CI job or a container image gets no warning from the rename otherwise — the daemon would simply stop seeing the path it was given and fall back to searching.

View Source
const UnauthenticatedListenRemedy = "\n\nWith mTLS off this agent authenticates nobody: anyone who can reach this port can run commands on this host as the account it runs as.\n" +
	"Either:\n" +
	"  - enroll this host and set tls.enabled: true, so callers are authenticated by certificate; or\n" +
	"  - listen on a loopback or private address — a tailnet or VPC address is what this posture is for; or\n" +
	"  - pass --allow-unauthenticated-public if this network authenticates its peers and you mean to serve there anyway."

UnauthenticatedListenRemedy is appended to the refusal wherever it reaches an operator, so the message that stops a start also says how to proceed.

Three ways out, in the order they should be preferred, and the flag last: enrolling is the posture the product is built around, a private address is the posture this option exists to support, and the flag is the one that leaves an unauthenticated agent on a reachable port.

View Source
const UnauthenticatedPrefix = "unauthenticated:"

UnauthenticatedPrefix begins the name of every principal this daemon did not authenticate.

It is the audit log's whole defence against ambiguity. A record whose principal is "whoever connected" must not read like one naming a verified certificate subject — otherwise the log quietly stops meaning what it meant, and every historical record with it. So an unauthenticated principal is spelled `unauthenticated:<peer address>`: it names the only identifying fact there is, and it cannot be mistaken for a common name at a glance or by a grep. policy.Record.PrincipalSource says the same thing in a field, for a reader that is matching rather than looking.

Variables

View Source
var ErrNoAllowedRoots = errors.New("agent: exec is disabled and allowed_roots is empty, which leaves no path jail; pass --no-jail to start anyway")

ErrNoAllowedRoots is returned by Config.Validate when the jail is enforced, the config confines the agent to nothing, and the operator has not explicitly accepted that.

On an agent with exec disabled, an empty root list is not a small misconfiguration: it is the difference between a service that can touch a workspace and one that can touch the whole filesystem, so it has to be asked for by name. On an exec-enabled agent it is not a condition at all — see Config.JailEnforced.

View Source
var ErrNoClientCertificate = errors.New("agent: client presented no certificate")

ErrNoClientCertificate is the authorization failure for a peer that presented no certificate at all. The TLS stack rejects that case before authorizePeer runs; this exists so the check is total on its own terms.

View Source
var ErrUnauthenticatedPublicListen = errors.New("agent: refusing to serve without mTLS on an address that is neither loopback nor private")

ErrUnauthenticatedPublicListen is the refusal at the centre of #85: an agent with mTLS off, binding an address that is neither loopback nor private, and no flag saying the operator meant it.

With mTLS off this daemon authenticates nobody. Its whole purpose is running commands on this host, so a reachable port with nothing in front of it is unauthenticated remote code execution — and the failure is silent, because an agent that skipped the CA ceremony works immediately and looks identical to a secured one. `--listen 0.0.0.0:8722` with no mTLS is the shape this exists to refuse.

It is a sentinel so the command that produced it can add the config path and the two ways out, and so a test can hold the refusal to this reason rather than to any startup failure at all.

Functions

func CheckListenPosture

func CheckListenPosture(cfg *Config, allowPublic bool) error

CheckListenPosture reports whether this config may open its listener.

It is the one function that decides, and it is called twice: by Config.Validate, which is what `fleet-agent serve` runs, and by New, which is what actually binds the socket. Both, because this repository has three times shipped a guard the running command reached by another path — and because the two callers are the two ways an agent starts.

With mTLS on it permits everything: the handshake is the boundary and a public address is a legitimate deployment. With mTLS off it permits only an address whose reachability is already bounded — loopback, or a private network — unless allowPublic says the operator has accepted the rest.

func DefaultConfigPath

func DefaultConfigPath() (string, error)

DefaultConfigPath resolves which config the daemon should read, in the order an operator would expect it to be found:

  1. $FLEET_AGENT_CONFIG (or the deprecated $SANDBOXD_AGENT_CONFIG), if set.
  2. The machine-wide path, if a file is actually there.
  3. The per-user enrollment directory.

It returns the per-user path even when nothing exists yet, so the error a caller reports names a concrete file rather than a search.

func DefaultLogDir

func DefaultLogDir() string

DefaultLogDir returns where the agent's audit log and, on platforms whose service manager does not capture stdout itself, its service logs are written.

func DefaultStateDir

func DefaultStateDir() string

DefaultStateDir returns where supervised process records and other daemon state are persisted. Uninstall deliberately leaves this directory alone.

func Register

func Register(name string, f Factory)

Register makes a service part of every fleet-agent daemon that imports its package. Call it from an init function:

func init() {
	agent.Register("exec", func(d agent.Deps) (agent.Service, error) {
		return &Service{jail: d.Jail, log: d.Log.With("service", "exec")}, nil
	})
}

The name is used for ordering and log lines, and must be unique — a duplicate panics at init, because two services claiming one name is a wiring mistake that should not survive to runtime.

func ResolveConfigPath

func ResolveConfigPath(explicit string) (string, error)

ResolveConfigPath returns explicit when it is non-empty, and otherwise the discovered default.

func ServerTLSConfig

func ServerTLSConfig(cfg *Config) (*tls.Config, error)

ServerTLSConfig builds the TLS configuration the agent's gRPC listener uses, or nil when this agent is configured to serve without mTLS.

A nil configuration means plaintext gRPC: nothing is authenticated and nothing is encrypted by this process. That is a posture, not a fallback — it is reached only from `tls.enabled: false`, it is refused outright on an address that is neither loopback nor private without an explicit flag, and the daemon says what it is at every start. See TLSConfig.Enabled and CheckListenPosture.

With mTLS on, every parameter of it is mandatory: the agent is a remote code execution service, and the only thing standing between it and the network is this handshake.

  • The agent presents its enrollment leaf, which the fleet CA issued under ca.ProfileAgent — a server-auth certificate. internal/client verifies it against the same CA and against the address it dialled.
  • Clients must present a certificate (tls.RequireAndVerifyClientCert) chaining to the same fleet CA.
  • On top of chain verification, the verified leaf must carry requireClientOU. Both agent and control leaves are signed by one CA, so the chain alone does not distinguish them; the OU is what says "issued to drive agents" rather than "issued to be an agent".

func SystemConfigDir

func SystemConfigDir() string

SystemConfigDir returns the machine-wide configuration directory, as documented at the top of examples/agent.yaml.

These are the paths the installer writes to when run with elevation. A per-user enrollment (`fleet-agent enroll` without root) lands under UserConfigDir instead, and DefaultConfigPath prefers whichever actually exists.

Every one of these directories was named "sandboxd" before the rebrand, and on a host that enrolled back then it is where the agent's certificates and key still are. The pre-rebrand path is used when it holds something and the new one does not; see internal/legacypath.

func UserConfigDir

func UserConfigDir() (string, error)

UserConfigDir returns the per-user enrollment directory, which is where `fleet-agent enroll` writes when no --dir is given.

Types

type AuditConfig

type AuditConfig struct {
	Path    string `yaml:"path"`
	Enabled bool   `yaml:"enabled"`
	// Required fails an RPC whose audit record could not be written, rather
	// than proceeding unrecorded.
	Required bool `yaml:"required,omitempty"`
	// MaxBytes is the size at which the log rotates, and RetainSegments how
	// many rotated segments are kept.
	MaxBytes       int64 `yaml:"max_bytes,omitempty"`
	RetainSegments int   `yaml:"retain_segments,omitempty"`
}

AuditConfig configures the forensic record written by #17.

type Config

type Config struct {
	// Name is the sandbox name this host enrolled under. It is informational
	// here — the authoritative identity is the common name in the leaf
	// certificate the agent presents.
	Name string `yaml:"name,omitempty"`

	// Listen is the address the gRPC server binds, as host:port.
	Listen string `yaml:"listen"`

	TLS TLSConfig `yaml:"tls"`

	// AllowedRoots are the absolute paths the jail confines filesystem access
	// to.
	//
	// They apply only to an agent with exec disabled. See ExecConfig.Enabled:
	// a caller who can run commands does not need FileService to reach a path,
	// so on an exec-enabled agent these are ignored rather than enforced
	// half-way. When they do apply, an empty list means no jail, which Validate
	// refuses unless AllowNoJail is set on the ValidateOptions.
	AllowedRoots []string `yaml:"allowed_roots"`

	Exec    ExecConfig    `yaml:"exec"`
	Shell   ShellConfig   `yaml:"shell"`
	Process ProcessConfig `yaml:"process"`
	Forward ForwardConfig `yaml:"forward"`
	Audit   AuditConfig   `yaml:"audit"`
	Log     LogConfig     `yaml:"log"`

	// StateDir is where supervised process records and other daemon state are
	// persisted. It survives uninstall, so re-installing rejoins the fleet
	// with its process history intact.
	StateDir string `yaml:"state_dir,omitempty"`

	// EnrolledAt and Addresses are recorded by `fleet-agent enroll` for
	// operator diagnostics.
	EnrolledAt string   `yaml:"enrolled_at,omitempty"`
	Addresses  []string `yaml:"addresses,omitempty"`

	// Legacy top-level certificate paths, written by the M0 enroll command
	// before the TLS block existed. Read-only: Load folds them into TLS so a
	// host enrolled against M0 still starts, and Save never writes them back.
	LegacyCertFile string `yaml:"cert_file,omitempty"`
	LegacyKeyFile  string `yaml:"key_file,omitempty"`
	LegacyCAFile   string `yaml:"ca_file,omitempty"`
	// contains filtered or unexported fields
}

Config is the agent's on-disk configuration, as documented in examples/agent.yaml.

func Load

func Load(path string) (*Config, error)

Load reads and validates the agent config at path.

Relative certificate, key, CA, state and audit paths are resolved against the config file's own directory: an operator who moves an enrollment directory wholesale should not have to rewrite every path inside it.

func (*Config) JailEnforced

func (c *Config) JailEnforced() bool

JailEnforced reports whether the path jail actually confines this agent.

It does so only when exec is disabled. With ExecService available, a caller never has to go through FileService to reach a path:

argv: ["sh", "-c", "echo pwned > /etc/passwd"]

needs no shell flag and no write RPC, and `tee`, `cp`, `dd` and `python -c` all do the same job. A path check that stops nobody while looking like a security control is worse than no check, because it is what operators plan around — so the jail is wired in only where it is real.

func (*Config) Logger

func (c *Config) Logger(w *os.File) (*slog.Logger, error)

Logger builds the daemon's slog logger from the config, writing to w.

func (*Config) Path

func (c *Config) Path() string

Path returns the file this config was loaded from, or "" for one built in memory.

func (*Config) Save

func (c *Config) Save(path string) error

Save writes the config to path atomically at mode 0600. The file names the private key and the roots the agent will serve; it is not world-readable.

Defaults are applied before writing, so the file shows the limits that will actually be in force. Writing the zero values out instead would produce a config reading `max_output_bytes: 0` on an agent whose real cap is 2 MiB — an operator would have no way to tell an unset field from a disabled one.

func (*Config) Validate

func (c *Config) Validate(opts ValidateOptions) error

Validate reports whether the config can actually run a daemon.

type Deps

type Deps struct {
	// Config is the loaded, validated agent configuration. Services read
	// their own section of it: exec.* for #7, process.* for #11, audit.* for
	// #17.
	Config *Config

	// Jail confines filesystem access to the configured roots. It is never
	// nil: a daemon with no confinement is handed jail.Unconfined(), which
	// normalises paths and permits all of them, so a service can call it
	// unconditionally instead of nil-checking on the request path.
	//
	// Prefer Jail.OpenFile over Jail.Resolve followed by os.OpenFile. On Linux
	// OpenFile hands the containment check to the kernel through openat2 with
	// RESOLVE_BENEATH, so no interval exists in which a component can be
	// swapped for a symlink pointing out of the jail; Jail.Atomic reports
	// whether this host got that or the portable fallback. Resolve is still
	// the right call for stat, readdir, rename and remove, and for deciding
	// whether a path is acceptable before acting on it — use the path it
	// returns, never the one you passed in. Map jail.ErrOutsideJail to
	// codes.PermissionDenied.
	//
	// It is only ever confining on an agent with exec disabled: a caller who
	// can run commands reaches any path without FileService, so the daemon
	// hands out an unconfined jail whenever exec is on. Do not read
	// Config.AllowedRoots as an answer about what is enforced — ask
	// Jail.Confined() and Jail.Roots(). That is also what GetHostInfo reports.
	Jail *jail.Jail

	// Policy is the command policy and the resource caps, built once per
	// daemon from the exec.* and process.* configuration.
	//
	// It is shared rather than per-service because a cap that each service
	// enforced from its own copy would not be a cap on the agent: two services
	// each holding "at most 32 concurrent processes" is a host running 64.
	// Take a concurrency slot with Policy.Acquire and release it when the
	// process is gone.
	//
	// The command lists are guardrails, not a boundary — see
	// internal/security/policy. Never describe them to a caller as
	// confinement.
	Policy *policy.Policy

	// Audit is the append-only record every service that runs a command,
	// writes a file, or signals a process appends to. Never nil; a daemon with
	// audit disabled hands out one that drops writes, so a call site needs no
	// conditional.
	//
	// One per daemon, deliberately: rotation renames the file, so a second
	// instance would keep appending to a segment the first has already rotated
	// away. Failure to write is the caller's decision to act on — see
	// Audit.Required — and the record must never carry environment values,
	// file contents, or command output.
	Audit *policy.Audit

	// Log is the daemon logger. Services should scope it, conventionally with
	// Log.With("service", "<name>").
	Log *slog.Logger

	// Status is the shared health state HostService.Health reports. The
	// supervisor registers its process count with it; anything that can make
	// the agent unable to serve should set a DEGRADED status on it.
	Status *Status

	// Version is the agent binary's version string, as reported by
	// GetHostInfo and Health.
	Version string

	// StartedAt is when the daemon process started, reported by GetHostInfo so
	// the control plane can detect a restart.
	StartedAt time.Time
}

Deps is everything the daemon hands a service implementation. It is passed to a Factory once, before the listener opens.

Every field is populated for every service. Jail is never nil — a daemon running without confinement supplies jail.Unconfined() rather than nothing, so a service can resolve unconditionally instead of nil-checking on the request path.

type Duration

type Duration time.Duration

Duration is a time.Duration that round-trips through YAML as the string form examples/agent.yaml uses ("120s", "1h"). gopkg.in/yaml.v3 has no built-in duration type, and decoding these as integer nanoseconds would make every value in the shipped example wrong by nine orders of magnitude.

func (Duration) Duration

func (d Duration) Duration() time.Duration

Duration returns the value as a time.Duration.

func (Duration) MarshalYAML

func (d Duration) MarshalYAML() (any, error)

MarshalYAML writes the duration back in its string form.

func (Duration) String

func (d Duration) String() string

String renders the duration.

func (*Duration) UnmarshalYAML

func (d *Duration) UnmarshalYAML(node *yaml.Node) error

UnmarshalYAML decodes a duration string ("120s", "1h") or a bare number, which is read as seconds.

The bare-number case is not permissiveness for its own sake: yaml.v3 decodes an unquoted 30 into a string just as happily as into a number, so without it an operator who wrote `default_timeout: 30` gets "missing unit in duration" rather than the two minutes they plainly meant.

type ExecConfig

type ExecConfig struct {
	// Enabled turns ExecService on. It defaults to true — running commands is
	// what this product is for — and turning it off is the only configuration
	// in which the path jail is a boundary rather than a decoration.
	//
	// A pointer because the default is true: a plain bool cannot tell
	// "enabled: false" from a key the operator never wrote.
	Enabled *bool `yaml:"enabled,omitempty"`

	DefaultTimeout Duration `yaml:"default_timeout"`
	MaxTimeout     Duration `yaml:"max_timeout"`
	MaxOutputBytes int64    `yaml:"max_output_bytes"`
	// DenyCommands and AllowCommands are the optional command policy. Both
	// empty is default-allow, which is honest about what this service is.
	DenyCommands  []string `yaml:"deny_commands"`
	AllowCommands []string `yaml:"allow_commands,omitempty"`
}

ExecConfig bounds one-shot command execution (#7) and is enforced centrally by the policy layer (#17).

func (ExecConfig) IsEnabled

func (e ExecConfig) IsEnabled() bool

IsEnabled reports whether ExecService is on. An unset field means yes.

type Factory

type Factory func(Deps) (Service, error)

Factory constructs a Service from the daemon's dependencies. Returning an error aborts startup: a service that cannot be built is a daemon that must not start serving as though it had been.

type ForwardConfig

type ForwardConfig struct {
	// Enabled turns ForwardService on. It defaults to true — forwarding a dev
	// server's port to the workstation is what closes the remote dev loop —
	// and an operator who wants the agent to do no networking on a caller's
	// behalf sets it to false.
	//
	// A pointer because the default is true: a plain bool cannot tell
	// "enabled: false" from a key the operator never wrote.
	Enabled *bool `yaml:"enabled,omitempty"`

	// AllowedHosts are the non-loopback hosts a forward may target on this
	// host's network. It is empty by default, and that default is the point.
	//
	// A forward to loopback reaches only what this agent's own machine is
	// serving. A forward to an arbitrary host reaches anything the machine's
	// network reaches — so an agent with no restriction is a general-purpose
	// network pivot into whatever it sits in, available to anyone who can call
	// it. On a fleet spanning a laptop, a home lab and a cloud VPC that is a
	// genuinely bad default, and it is bad in a way nobody notices until it is
	// used, because forwarding to loopback works perfectly without it.
	//
	// An entry is a hostname, an IP address, or a CIDR block. A hostname is
	// matched literally against the requested host, case-insensitively. An
	// address or a block is matched against the addresses the target resolves
	// to, so listing 10.0.4.7 permits it under any name it answers to — the
	// packets reach the same machine either way, which is the thing the
	// operator actually decided.
	//
	// Anything not matched must resolve entirely to loopback addresses or the
	// connection is refused. "Entirely" is the whole check: a name resolving to
	// both a permitted address and one outside the list is refused, because
	// passing on the strength of whichever came back first is not a decision
	// anyone made.
	//
	// This is the `allow_hosts` of #45, under the name #26 shipped it as. One
	// list, not two: an operator deciding which network this agent may reach is
	// making one decision, and a second list would let a host be reachable one
	// way and not the other for no reason a reader could recover.
	AllowedHosts []string `yaml:"allowed_hosts,omitempty"`

	// SocksEnabled permits SOCKS5-proxied connections through this agent —
	// `fleetctl socks` and fleet_socks. It defaults to false, and that default
	// is the security posture of the whole feature.
	//
	// A port forward reaches a host and port the caller named up front. A proxy
	// reaches whatever a client asks for, connection by connection, which makes
	// the agent a general-purpose route into its network rather than a route to
	// one service on it. Those are different grants, so they are different
	// settings: an agent with AllowedHosts set still forwards to exactly the
	// hosts it always did, and serves no proxy at all, until an operator turns
	// this on.
	//
	// With it on and AllowedHosts empty, a proxied connection may reach any host
	// this machine can. That is a legitimate choice for a throwaway lab box and
	// a bad one everywhere else, so the agent says so in its log at every start.
	//
	// A plain bool, unlike Enabled above: the default is false, so a key nobody
	// wrote and a key written as false mean the same thing and there is nothing
	// for a pointer to distinguish.
	SocksEnabled bool `yaml:"socks_enabled,omitempty"`

	// MaxConnections bounds the concurrent connections this agent will carry
	// over ForwardService — a port forward's and a SOCKS proxy's alike, since
	// both are one stream per connection. Zero means the default.
	//
	// A proxy is what makes this number visible: a forward carries one client's
	// connections to one port, and a proxy carries whatever is pointed at it, so
	// a browser or a parallel fetch reaches a bound a forward never would. The
	// connection past it is refused, not queued, and the caller is told which
	// setting refused it.
	MaxConnections int `yaml:"max_connections,omitempty"`

	// DialTimeout bounds the connection to the sandbox-side port. Zero means
	// the default.
	DialTimeout Duration `yaml:"dial_timeout,omitempty"`
}

ForwardConfig bounds the port forwarder (#26).

The one setting that matters here is AllowedHosts, and its default of "none" is a security decision rather than a conservative-looking blank. See the field.

func (ForwardConfig) AddressAllowed

func (f ForwardConfig) AddressAllowed(ip net.IP) bool

AddressAllowed reports whether ip is covered by an address or CIDR entry on the allow list.

Hostname entries are ignored here on purpose. Resolving them to compare addresses would make the answer depend on what DNS said at this instant, for a name the operator wrote precisely because the name is the stable part — and it would turn one allow-list lookup into a resolver call per entry per connection. A name is matched as a name, by ForwardConfig.HostAllowed; an address is matched as an address, here.

A malformed entry matches nothing. It cannot be rejected at load time without turning a typo in one line into a daemon that will not start, and failing open on an allow-list entry nobody can parse is the one direction that must not happen — so it is dropped here and reported at startup by ForwardConfig.MalformedAllowedHosts.

func (ForwardConfig) FullCoverAllowedHosts

func (f ForwardConfig) FullCoverAllowedHosts() []string

FullCoverAllowedHosts returns the allow-list entries that cover their whole address family, each rendered with what it permits.

A /0 block is a valid, deliberate-looking way to write "everywhere", and it is what an operator reaches for when they want to unblock something quickly — including when fleet_socks refuses and tells them, in as many words, to "list the hosts, addresses or CIDR blocks the proxy should reach". An allow list holding one is not narrowed by any reading; it just does not look like an empty one, which is the only shape ForwardConfig.SocksAllowsAnyHost could see. So the agent's loudest line went unsaid for the configuration it exists to say it about, and the tool that refuses "any host" served it.

Only a mask of length zero counts. Two half-blocks that add up to everything, or a /1, are not caught, and that is deliberate: this is the same shape as ForwardConfig.WidenedAllowedHosts — a rule that names the plausible mistake rather than one that pretends to do CIDR arithmetic and would still miss the next spelling. The boundary does not rest on it either way; it is what the agent says about itself.

func (ForwardConfig) HostAllowed

func (f ForwardConfig) HostAllowed(host string) bool

HostAllowed reports whether host is named literally on the allow list.

It answers only that question. A host that is not listed is not thereby refused — it is refused unless it resolves entirely to loopback or to addresses ForwardConfig.AddressAllowed accepts, which is the caller's check, because it needs a resolver and a context.

func (ForwardConfig) IsEnabled

func (f ForwardConfig) IsEnabled() bool

IsEnabled reports whether ForwardService is on. An unset field means yes.

func (ForwardConfig) MalformedAllowedHosts

func (f ForwardConfig) MalformedAllowedHosts() []string

MalformedAllowedHosts returns the allow-list entries that are neither a usable CIDR block nor anything a hostname may legally be.

An entry like "10.0.0.0/33" or "10.0.0.0 /8" parses as neither a block nor an address, so it silently becomes a hostname that no request will ever match — an allow list that reads as permitting a subnet and permits nothing. That is a configuration worth a line in the log, and it is not worth refusing to start over: the failure is closed, and an agent that will not boot because of a stray character in a setting it may never use is a worse outcome than one that boots and says so.

func (ForwardConfig) SocksAllowsAnyHost

func (f ForwardConfig) SocksAllowsAnyHost() bool

SocksAllowsAnyHost reports the configuration in which a proxied connection is dialed with no check at all: proxying on, with an empty allow list.

It is the *dialing* question, not the posture question, and the two are not the same — see ForwardConfig.SocksReachesAnyHost, which is what anything describing this agent should ask. An allow list of ["0.0.0.0/0"] permits every IPv4 host and still goes through the resolve-and-check path, which is what keeps it from also permitting IPv6.

func (ForwardConfig) SocksReachesAnyHost

func (f ForwardConfig) SocksReachesAnyHost() bool

SocksReachesAnyHost reports that a proxy through this agent is bounded by nothing but the machine's own network.

It is what the startup banner, GetHostInfo.forward_policy and fleet_socks's refusal all mean by "unrestricted", and it is one question with two spellings in the configuration: an empty allow list, or one holding a block that covers everything.

func (ForwardConfig) WidenedAllowedHosts

func (f ForwardConfig) WidenedAllowedHosts() []string

WidenedAllowedHosts returns the allow-list entries whose CIDR block covers more than the address written in front of the mask, each rendered as the block it actually permits.

"10.0.4.7/24" is a valid block and a plausible way to write "this one host", and it permits two hundred and fifty-four others. Nothing in ForwardConfig.MalformedAllowedHosts can see it — net.ParseCIDR succeeds, because the entry is not malformed, only wider than it reads. The cost of getting it wrong is an operator who believes they narrowed the pivot to one machine and narrowed it to a subnet, which is exactly the mistake this whole setting exists to prevent.

So it is a line in the log rather than a refusal to start: the semantics are the ones every other tool applies to a CIDR, and an agent that would not boot over a mask an operator meant is worse than one that boots and says what the mask means.

type LogConfig

type LogConfig struct {
	// Level is one of debug, info, warn, error.
	Level string `yaml:"level,omitempty"`
	// Format is "text" or "json".
	Format string `yaml:"format,omitempty"`
}

LogConfig configures the daemon's own structured logging.

type Options

type Options struct {
	// Config is the loaded, validated agent configuration. Required.
	Config *Config

	// Log is the daemon logger. Required.
	Log *slog.Logger

	// Version is the agent binary's version, reported by HostService.
	Version string

	// Services are the service registrations to host. Nil means Registered(),
	// which is every service package linked into the binary. Tests pass an
	// explicit slice.
	Services []Registration

	// Listener overrides the socket the server accepts on. Nil means listen on
	// Config.Listen. Tests pass a bufconn listener, which is the only way to
	// exercise the real TLS stack without binding a port.
	Listener net.Listener

	// DrainTimeout bounds the wait for in-flight RPCs during shutdown. Zero
	// uses DefaultDrainTimeout.
	DrainTimeout time.Duration

	// Jail overrides the path jail built from Config.AllowedRoots. Nil builds
	// one from the config.
	Jail *jail.Jail

	// GRPCOptions are appended to the server options this package builds.
	GRPCOptions []grpc.ServerOption

	// AllowUnauthenticatedPublic carries `serve --allow-unauthenticated-public`
	// through to the check that opens the listener.
	//
	// [Config.Validate] makes the same check, and the command runs it first.
	// This one is not redundant: Validate is a call a caller can skip, and this
	// is the function that actually binds the socket. The guard belongs on the
	// path that cannot be gone around, and a repository whose recurring defect
	// is a fix the running command never reached does not get to have it in
	// only one of the two places.
	//
	// It is ignored when Listener is set, because then no address from the
	// config is bound at all — a bufconn has no reachability to judge.
	AllowUnauthenticatedPublic bool
}

Options configures a Server.

type Principal

type Principal struct {
	// Name identifies the caller: the common name from its verified client
	// certificate, or `unauthenticated:<peer address>`.
	Name string
	// Authenticated reports that Name came from a certificate chain this agent
	// verified against the fleet CA. False means the network decided who may
	// reach this port, and this agent checked nothing.
	Authenticated bool
}

Principal is who the daemon is serving this RPC for, and how it knows.

The two fields are not redundant. Name is what a human reads; Authenticated is what a machine matches on, and it is false for every RPC on an agent serving without mTLS — where "who is calling" has no answer this process can verify, only an address the network handed it.

func PrincipalFromContext

func PrincipalFromContext(ctx context.Context) (Principal, bool)

PrincipalFromContext returns the identity the daemon resolved for this RPC.

This is the value HostService echoes as authenticated_principal and the one every audit record (#17) is keyed on. With mTLS on it is derived from the verified chain during the TLS handshake, not from anything the caller sends, so it cannot be spoofed by a request field. With mTLS off there is no chain to derive anything from and it names the peer address instead — see Principal and UnauthenticatedPrefix.

The second return is false for a context that did not come from a served RPC, which in practice means a bug in a test harness rather than an unauthenticated caller: an agent serving mTLS rejects those at the handshake, and one serving without it still has a peer address for every real call.

func (Principal) Source

func (p Principal) Source() policy.PrincipalSource

Source is how the audit log names what established this principal.

func (Principal) String

func (p Principal) String() string

String renders the principal as it is recorded and echoed.

type ProcessConfig

type ProcessConfig struct {
	// MaxConcurrent is an agent-wide cap, not a per-service one. It is spelled
	// under process.* because supervised processes are what it was written
	// for, but what it bounds is how many processes this agent has running on
	// somebody's host, and that is one quantity however many services can
	// spawn one. Every such service takes its slots from the single limiter
	// built from it; see Deps.Policy.
	MaxConcurrent      int      `yaml:"max_concurrent"`
	MaxLogBytes        int64    `yaml:"max_log_bytes"`
	RingBufferLines    int      `yaml:"ring_buffer_lines"`
	DefaultGracePeriod Duration `yaml:"default_grace_period"`
	MaxFollowDuration  Duration `yaml:"max_follow_duration"`
}

ProcessConfig bounds the background process supervisor (#11–#15).

type Registration

type Registration struct {
	Name    string
	Factory Factory
}

Registration is a named factory.

func Registered

func Registered() []Registration

Registered returns every registered service, ordered by name so a daemon's startup is reproducible regardless of import order.

type Server

type Server struct {
	// contains filtered or unexported fields
}

Server is the agent daemon: a gRPC listener — mutually authenticated unless the operator has said otherwise — hosting every registered service, with a shutdown path that drains RPCs without disturbing supervised background processes.

func New

func New(opts Options) (srv *Server, err error)

New builds the server: it loads the TLS material, constructs every registered service, registers their handlers, and opens the listener.

Everything that can fail does so here, before the daemon claims to be serving. Nothing is accepted until Serve is called.

func (*Server) Addr

func (s *Server) Addr() net.Addr

Addr returns the address the server accepts on.

func (*Server) Deps

func (s *Server) Deps() Deps

Deps returns the dependencies handed to every service, for a caller that needs the same jail, status, or logger the services got.

func (*Server) Serve

func (s *Server) Serve(ctx context.Context) error

Serve accepts connections until ctx is cancelled, then shuts down gracefully and returns.

Shutdown, in order:

  1. Health flips to DRAINING, so a control plane polling the fleet sees the agent going away rather than a connection error it has to guess about.
  2. The listener stops accepting and in-flight RPCs are given DrainTimeout to finish. A call still running when that expires is cut.
  3. Each service implementing Shutdowner is given what remains of the deadline to flush its own state.

What does not happen at any point is the daemon touching a supervised process. Those are owned by the host and outlive the daemon deliberately; see Shutdowner.

func (*Server) ServiceNames

func (s *Server) ServiceNames() []string

ServiceNames returns the names of the hosted services, in registration order.

func (*Server) Stop

func (s *Server) Stop()

Stop closes the listener and drops in-flight RPCs without draining. It exists for tests and for a caller that has already lost patience; the normal path is cancelling the context passed to Serve.

It skips the shutdown participants deliberately — that is what "without draining" means — but it does release the audit log, because that is an OS handle rather than a participant's state. A handle nobody will close is a file that cannot be renamed or removed on Windows for as long as the process lives, which would make the impatient path leave the log undeletable by the very caller that gave up on the server. Closing is idempotent and a later write reopens the file, so this costs a shutdown nothing.

type Service

type Service interface {
	// Register attaches the generated handlers to the gRPC server. It is
	// called once, before the listener opens.
	Register(grpc.ServiceRegistrar)
}

Service is one gRPC service hosted by the daemon.

A service may additionally implement Shutdowner to take part in graceful shutdown.

type ShellConfig

type ShellConfig struct {
	// Enabled turns ShellService on. It defaults to true, and turning it off
	// is a convenience rather than a boundary: a caller who can call
	// ExecService can already run whatever it likes on this host, so an
	// operator who wants that stopped wants exec.enabled: false, which turns
	// this off too. What this setting buys is an agent that runs commands for
	// a model and refuses to hand anybody an interactive terminal.
	//
	// A pointer because the default is true: a plain bool cannot tell
	// "enabled: false" from a key the operator never wrote.
	Enabled *bool `yaml:"enabled,omitempty"`

	// IdleTimeout ends a session that has carried no bytes in either
	// direction for this long.
	//
	// Both directions, deliberately. A session watching a long build produces
	// no keystrokes for an hour and is not abandoned; one whose operator shut
	// their laptop produces nothing either way. Counting only input would reap
	// the first, which kills a running job because nobody typed.
	//
	// Zero means the default. There is no value that disables it: an
	// interactive session holds a pseudo-terminal, a process tree and a
	// concurrency slot on somebody's machine, and "forever" is not a bound. An
	// operator who needs longer sessions sets a longer timeout.
	IdleTimeout Duration `yaml:"idle_timeout,omitempty"`
}

ShellConfig bounds the interactive shell (#43).

func (ShellConfig) IsEnabled

func (s ShellConfig) IsEnabled() bool

IsEnabled reports whether ShellService is on. An unset field means yes.

type Shutdowner

type Shutdowner interface {
	Shutdown(context.Context) error
}

Shutdowner is the optional half of Service: a hook run once in-flight RPCs have drained.

The contract is narrow on purpose. Shutdown means "stop serving and flush your own state". It does not mean "stop the work you started": supervised background processes are owned by the host, not by the daemon that spawned them, and surviving a daemon restart is the entire reason they exist. An agent upgrade must not take down every dev server in the fleet.

So the supervisor's Shutdown persists its process records and returns. It must not signal a child, and the daemon never signals one on its behalf — which is also why the systemd unit this repository installs sets KillMode=process and the launchd job sets AbandonProcessGroup.

The context passed in carries the shutdown deadline. Overrunning it does not stop the daemon exiting.

type Status

type Status struct {
	// contains filtered or unexported fields
}

Status is the daemon's shared liveness state, read by HostService.Health and written by the daemon and by whichever service owns a piece of it.

Health is called on a timer by every connected MCP server, so answering it must cost nothing: no filesystem stats, no shelling out, no locks. Every operation here is an atomic load or store.

func NewStatus

func NewStatus() *Status

NewStatus returns a Status reporting SERVING with no supervised processes.

func (*Status) Set

func (s *Status) Set(state sandboxdv1.HealthResponse_Status, message string)

Set records the daemon's health. message is surfaced to callers and should be empty when the status is SERVING.

func (*Status) SetProcessCounter

func (s *Status) SetProcessCounter(count func() uint32)

SetProcessCounter registers the function Health calls for the supervised process count. The supervisor (#11) calls this from its constructor; until it does, Health reports zero running processes.

The function is called on the Health path, so it must be an atomic read of an already-maintained counter rather than a walk of the process table.

func (*Status) Snapshot

func (s *Status) Snapshot() (state sandboxdv1.HealthResponse_Status, message string, running uint32)

Snapshot returns the current health, its explanation, and the number of supervised processes.

type TLSConfig

type TLSConfig struct {
	// Enabled turns mutual TLS on. It is the whole of this agent's own
	// authentication, and turning it off is a decision about the network this
	// host sits on rather than a convenience.
	//
	// With it off the agent serves plaintext gRPC: no client certificate is
	// demanded, the agent presents none, and nothing is encrypted by this
	// product. Whatever authenticates the caller is the network — a Tailscale
	// tailnet, a WireGuard mesh, a VPC with tight security groups — and
	// nothing else. On a network that does not do that, an agent serving
	// without mTLS is unauthenticated remote code execution; the daemon
	// refuses to open a listener that is neither loopback nor private without
	// an explicit flag, and says what it is at every start either way. See
	// [CheckListenPosture] and docs/security.md.
	//
	// A pointer, because the default is neither constant nor "true": an unset
	// field means "on if this config names TLS material". That keeps the two
	// cases apart that a plain bool would merge — a config written by
	// `fleet-agent enroll`, which names a leaf and a CA and must keep
	// authenticating after an upgrade, and one hand-written for a tailnet,
	// which names none and never wanted a CA. Nothing here silently downgrades
	// an enrolled agent, and nothing demands a certificate from a host that
	// never enrolled.
	Enabled *bool `yaml:"enabled,omitempty"`

	// Certificate is the agent's server-auth leaf, issued during enrollment.
	Certificate string `yaml:"certificate"`
	// PrivateKey is the key generated on this host at enrollment. It has
	// never left the machine.
	PrivateKey string `yaml:"private_key"`
	// CABundle is the fleet CA. Client certificates must chain to it.
	CABundle string `yaml:"ca_bundle"`
	// RequireClientOU is the organizational unit a client leaf must carry on
	// top of chaining to the fleet CA. Empty means DefaultClientOU; it never
	// means "any OU".
	RequireClientOU string `yaml:"require_client_ou"`
}

TLSConfig names the identity the agent serves with and the CA it authenticates clients against — or says that it does neither.

func (TLSConfig) Configured

func (t TLSConfig) Configured() bool

Configured reports whether this block names any TLS material at all.

It is the inference above, and separately it is what makes "mTLS is off and there are certificates here" sayable: that combination is a config the operator should hear about at every start, since the files are there and are doing nothing.

func (TLSConfig) IsEnabled

func (t TLSConfig) IsEnabled() bool

IsEnabled reports whether mutual TLS is in force.

An unset Enabled infers it from the material: a config naming a leaf, a key or a CA bundle was written by enrollment and authenticates, and one naming none never enrolled and cannot. The inference only ever runs for a config that says nothing, and [Config.applyDefaults] writes the answer down, so what a daemon does and what its file says stay the same thing.

type ValidateOptions

type ValidateOptions struct {
	// AllowNoJail permits an empty allowed_roots list. It is `serve
	// --no-jail`, and the daemon logs a warning on every start when it is set.
	AllowNoJail bool

	// AllowUnauthenticatedPublic permits serving without mTLS on an address
	// that is neither loopback nor private. It is `serve
	// --allow-unauthenticated-public`.
	//
	// A second flag rather than a config key, deliberately. The posture it
	// unlocks is the one an operator can end up in by accident — a config
	// copied from another host, a `--listen 0.0.0.0` typed for convenience —
	// and a key in a file is inherited silently, while a flag has to be typed
	// or written into a unit by the person who owns the machine.
	AllowUnauthenticatedPublic bool
}

ValidateOptions carries the decisions an operator makes on the command line rather than in the config file.

Directories

Path Synopsis
Package exec implements ExecService: one-shot command execution with streaming output, wall-clock timeouts, and output caps.
Package exec implements ExecService: one-shot command execution with streaming output, wall-clock timeouts, and output caps.
Package forward implements sandboxd.v1.ForwardService: the sandbox half of `ssh -L`.
Package forward implements sandboxd.v1.ForwardService: the sandbox half of `ssh -L`.
Package fs implements FileService: read, write, edit, list, stat, glob, grep, and the three path-management RPCs — make directory, remove and move.
Package fs implements FileService: read, write, edit, list, stat, glob, grep, and the three path-management RPCs — make directory, remove and move.
Package host implements HostService: platform and resource introspection, toolchain detection, and health reporting.
Package host implements HostService: platform and resource introspection, toolchain detection, and health reporting.
Package process implements ProcessService: the supervisor for long-running background processes.
Package process implements ProcessService: the supervisor for long-running background processes.
Package shell implements sandboxd.v1.ShellService: one interactive pseudo-terminal session per stream.
Package shell implements sandboxd.v1.ShellService: one interactive pseudo-terminal session per stream.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL