broker

package
v0.17.2 Latest Latest
Warning

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

Go to latest
Published: Sep 15, 2026 License: MIT Imports: 17 Imported by: 0

Documentation

Overview

Package broker carries command execution across the sandbox boundary. The in-sandbox client sends a command line over a unix socket; the server, which runs outside the agent's own sandbox, interprets the shell language itself 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.

Index

Constants

View Source
const (
	PolicyLaunchBurst    = 4
	PolicyLaunchInterval = 500 * time.Millisecond
)

PolicyLaunchBurst and PolicyLaunchInterval pace how fast policy-controlled commands may be launched: PolicyLaunchBurst of them may start with no wait at all, and the budget refills at one launch per PolicyLaunchInterval. See launchPacer for what is being rationed and how the numbers were arrived at.

View Source
const SandboxNotRunningHint = "command broker is not available; run Claude via `agent-sandbox claude`, which starts it automatically"

SandboxNotRunningHint is the actionable message shown when the broker is not reachable. It is exported because the situation is detected before any command runs: `agent-sandbox exec` and the MCP server both fail to build a client when AGENT_SANDBOX_BROKER_SOCKET is unset, and must print this rather than a raw dial error.

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) RunCommand added in v0.17.0

func (c *Client) RunCommand(ctx context.Context, command string,
	stdin io.Reader, stdout, stderr io.Writer) (int, error)

RunCommand sends one command line to the broker and streams its output back.

type CommandRunner added in v0.17.0

type CommandRunner interface {
	RunCommand(ctx context.Context, command string, stdin io.Reader,
		stdout, stderr io.Writer) (int, error)
}

CommandRunner executes one command line inside the sandbox. The broker client is the production implementation; tests substitute their own.

type Executor

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

Executor runs one command line. The production implementation is ShellExecutor; tests substitute a fake so the server can be exercised without spawning anything.

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 Request

type Request struct {
	Command string `json:"command"`
	// Cwd is client-controlled: it comes straight from the sandboxed agent's
	// own working directory (see Client.RunCommand's workingDir helper). It
	// reaches the interpreter's Dir option and, through it, --workdir of the
	// commands the interpreter execs, but nothing in this package bounds it to
	// any particular root — see ShellExecutor.Execute for where that bound
	// actually lives.
	Cwd       string `json:"cwd"`
	WithStdin bool   `json:"with_stdin"`
}

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

It carries a command *line*, not an argv. The broker interprets the shell language itself and executes each simple command, so splitting it here would duplicate that work in the one place that cannot see the result.

There is deliberately no environment field. The command's environment is a policy decision owned by the command profile: the broker's own environment is filtered by nono before it starts, and each command's is decided by its entry. A request-supplied environment could not work — the agent's nono profile strips those variables long before they could be reported — and must not work, because the request originates inside the sandbox it would configure.

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 dedicated broker process — outside the agent's own sandbox, but inside a nono session of its own — which is the point: a process cannot create a new sandbox boundary around itself, so the broker needs a boundary of its own rather than borrowing the agent's, or worse, running with the unsandboxed launcher's own reach.

That session is started by internal/claude.startCommandBroker, which runs `nono run --profile <command profile> -- agent-sandbox broker --socket <path>` (see claude.BrokerArgs) as a sibling of the agent's own sandbox, not a child of it — nono refuses to nest.

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.

type ShellExecutor added in v0.17.0

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

ShellExecutor runs one command line. It parses and evaluates the shell language in this process with mvdan.cc/sh and executes every simple command itself, which is what keeps each execution mediated: the broker runs inside a nono session whose command policies decide what may be executed at all, and handing the line to a real shell would hand that decision to the shell.

Everything the shell language does with the filesystem — globbing, redirects, command substitution — is performed here and is therefore bounded by the broker's own sandbox, not by the agent's.

func NewShellExecutor added in v0.17.0

func NewShellExecutor() *ShellExecutor

NewShellExecutor returns a ShellExecutor. Its only state is the launch pacer below, which is deliberately per-executor rather than per-request: see the pacer field.

func (*ShellExecutor) Execute added in v0.17.0

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

Execute satisfies Executor so the server can run a request directly. The server owns the transport; ShellExecutor owns the shell language.

req.Cwd is client-controlled and flows straight into interp.Dir, and from there into every command's own working directory. The old router-based design (NonoExecutor.checkCwd) rejected a relative path or one outside the granted root itself; this executor does not reproduce that check, because the bound it enforced now comes from the broker's own nono session instead: the broker runs under --profile with no --allow-cwd (see BrokerArgs), so every filesystem access the interpreter or a child process makes — cwd included — is already confined to whatever that profile grants, whatever req.Cwd claims. What is checked here is only the request's shape, not its reach: a non-absolute Cwd (including the empty string a client sends when its own os.Getwd fails) is refused rather than silently resolved against this process's own working directory, which would not be the directory the agent thinks it is running commands in.

func (*ShellExecutor) Run added in v0.17.0

func (e *ShellExecutor) Run(ctx context.Context, command, cwd string,
	stdin io.Reader, stdout, stderr io.Writer) (int, error)

Run evaluates command with cwd as the working directory, streaming output to stdout and stderr, and returns the exit status of the last command.

The error is non-nil only for a failure of Run itself. A syntax error, a command that does not exist, and a command that fails are all reported through the exit status with a message on stderr, because they are outcomes of the agent's line rather than faults of the broker.

Jump to

Keyboard shortcuts

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