broker

package
v0.16.2 Latest Latest
Warning

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

Go to latest
Published: Aug 28, 2026 License: MIT Imports: 14 Imported by: 0

Documentation

Overview

Package broker carries command execution across the sandbox boundary. The in-sandbox client sends a command over a unix socket; the host-side server, which lives in the launcher process outside the sandbox, runs it under its own nono sandbox and streams the output back.

The wire format is one request per connection, followed by length-prefixed frames in both directions. Frames keep stdout and stderr separate so a caller can route them independently, which the router's mixed host/sandbox pipelines rely on.

Index

Constants

View Source
const SocketEnvVar = "AGENT_SANDBOX_BROKER_SOCKET"

SocketEnvVar names the environment variable that carries the broker socket path into the sandbox.

Variables

View Source
var ErrBrokerUnavailable = errors.New("command broker is not available")

ErrBrokerUnavailable signals that the broker socket could not be reached. Callers translate it into an actionable message instead of a raw dial error.

Functions

func WriteError

func WriteError(w io.Writer, msg string) error

WriteError writes a terminal error frame carrying a human-readable message.

func WriteExit

func WriteExit(w io.Writer, code int) error

WriteExit writes the terminal exit frame.

func WriteFrame

func WriteFrame(w io.Writer, ch Channel, payload []byte) error

WriteFrame writes one frame: 1 byte channel, 4 bytes big-endian length, payload.

func WriteRequest

func WriteRequest(w io.Writer, req Request) error

WriteRequest writes the JSON request with a 4-byte big-endian length prefix.

Types

type Channel

type Channel byte

Channel identifies which stream a frame belongs to.

const (
	ChanStdout Channel = 1
	ChanStderr Channel = 2
	ChanStdin  Channel = 3
	ChanExit   Channel = 4
	ChanError  Channel = 5
	// ChanStdinClose signals end-of-input; a zero-length stdin frame would be
	// ambiguous with "no bytes available yet".
	ChanStdinClose Channel = 6
)

type Client

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

Client dials the broker socket. It is safe for concurrent use: every call opens its own connection, which is what lets a mixed pipeline run several sandboxed segments at once.

func NewClient

func NewClient(sockPath string) *Client

NewClient returns a client for the socket at sockPath.

func NewClientFromEnv

func NewClientFromEnv() (*Client, error)

NewClientFromEnv builds a client from SocketEnvVar. It returns ErrBrokerUnavailable when the variable is unset, which happens whenever a command is routed to the sandbox outside an `agent-sandbox claude` session.

func (*Client) RunSandboxed

func (c *Client) RunSandboxed(ctx context.Context, argv []string,
	stdin io.Reader, stdout, stderr io.Writer) (int, error)

RunSandboxed sends one command to the broker and streams its output back. It matches the router's runner interface.

type Executor

type Executor interface {
	Execute(ctx context.Context, req Request, stdin io.Reader,
		stdout, stderr io.Writer) (int, error)
}

Executor runs one command. The production implementation spawns nono; tests substitute a fake so the server can be exercised without a sandbox.

type Frame

type Frame struct {
	Channel Channel
	Payload []byte
}

Frame is one decoded frame.

func ReadFrame

func ReadFrame(r io.Reader) (Frame, error)

ReadFrame reads one frame. It returns an error wrapping io.EOF when the stream ends cleanly at a frame boundary.

func (Frame) ExitCode

func (f Frame) ExitCode() int

ExitCode decodes an exit frame's payload. It is meaningless on other channels.

type NonoExecutor

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

NonoExecutor runs each command in its own nono sandbox, using a profile the launcher generated once at startup.

func NewNonoExecutor

func NewNonoExecutor(nonoPath, profilePath, workdir string, envAllow []string) *NonoExecutor

NewNonoExecutor returns an Executor that shells out to nono at nonoPath with the command profile at profilePath.

workdir is the directory the command profile grants; a request may only run inside it.

envAllow is the command profile's own environment allow_vars list — pass sandboxhost.Resolved.EnvAllowVars() for the same profile written to profilePath. It is the single source of truth for the process environment: the executor forwards exactly those of the LAUNCHER's variables that the profile already declares, so the supervisor's environment can never grant more than the sandbox itself would allow. Passing an empty list yields an empty environment, which is almost never what a caller wants.

func (*NonoExecutor) Args

func (e *NonoExecutor) Args(req Request) []string

Args builds the nono argv for req. Exported so the argv shape is testable without spawning anything.

func (*NonoExecutor) Execute

func (e *NonoExecutor) Execute(ctx context.Context, req Request, stdin io.Reader,
	stdout, stderr io.Writer) (int, error)

Execute runs one command and streams its output to stdout/stderr.

stdin is pumped through cmd.StdinPipe() by a goroutine this method owns, rather than being handed to os/exec as cmd.Stdin. That is deliberate: with cmd.Stdin set, Wait blocks until os/exec's own copier finishes, and that copier sits in stdin.Read() — which nothing interrupts when the peer feeding stdin neither writes nor closes (`tail -f x | grep -m1 y`, with grep in the sandbox). Wait closes a StdinPipe as soon as the process exits, so the exit status is always reported; the pump goroutine unblocks separately when the server closes its end.

func (*NonoExecutor) ProcessEnv

func (e *NonoExecutor) ProcessEnv() []string

ProcessEnv builds the environment for the nono process itself: the LAUNCHER's own values for exactly those names the command profile's allow_vars permits.

The source matters as much as the filter. The broker runs outside the sandbox, so anything the sandboxed side could supply is untrusted input — a request-supplied XDG_STATE_HOME would redirect nono's own audit ledger and session state, letting the sandboxed side suppress its audit trail and making the unsandboxed supervisor write files anywhere the user can write. Reading from the launcher removes that class entirely; nothing here is request-controlled. It is also the only source that works: the agent's own nono profile does not allow-list sandbox.command.env_passthrough, so those variables are stripped before the sandboxed router could ever observe them.

The NONO_* strip is kept as defence in depth: nono reads its own policy from the environment (NONO_ALLOW_DOMAIN, NONO_NETWORK_PROFILE, NONO_BLOCK_NET, NONO_PROFILE, NONO_TRUST_OVERRIDE, ...), so a single leaked variable would let the sandboxed side rewrite the policy meant to contain it. Config validation already rejects NONO_* in env_passthrough.

type Request

type Request struct {
	Argv      []string `json:"argv"`
	Cwd       string   `json:"cwd"`
	WithStdin bool     `json:"with_stdin"`
}

Request is the first message on a connection: what to run and where.

There is deliberately no environment field. The command's environment is a policy decision owned by the launcher, which resolves it outside the sandbox from the command profile's allow_vars (see NonoExecutor.ProcessEnv). A request-supplied environment could not work — the agent's nono profile strips those variables long before the router could report them — and must not work, because the request originates inside the sandbox it configures.

func ReadRequest

func ReadRequest(r io.Reader) (Request, error)

ReadRequest reads a request written by WriteRequest.

type Server

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

Server accepts one command per connection on a unix socket. It runs in the launcher process, outside the sandbox, which is the whole point: a process inside the sandbox cannot create a new sandbox boundary.

func NewServer

func NewServer(sockPath string, exec Executor) (*Server, error)

NewServer creates the socket at sockPath with 0600 permissions. An existing stale socket at that path is removed first: a previous run that was killed leaves the file behind, and bind would otherwise fail forever.

func (*Server) Close

func (s *Server) Close() error

Close stops accepting and removes the socket file.

func (*Server) Serve

func (s *Server) Serve()

Serve accepts connections until Close is called. It is meant to run in its own goroutine.

func (*Server) SocketPath

func (s *Server) SocketPath() string

SocketPath returns the path clients dial.

Jump to

Keyboard shortcuts

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