execd

package
v0.18.0 Latest Latest
Warning

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

Go to latest
Published: Sep 22, 2026 License: MIT Imports: 19 Imported by: 0

Documentation

Overview

Package execd carries command execution across the sandbox boundary. The in-sandbox client sends a command line over a unix socket, along with its own stdin, stdout and stderr descriptors; the server, which runs outside the agent's own sandbox, interprets the shell language itself and the command writes through those descriptors directly.

The wire format is one request per connection: a descriptor handshake, the request, and then length-prefixed frames in both directions for what is left — a signal from the client, and a terminal exit or error from the server.

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 (
	ExitSyntaxError = 2
	// ExitTimeout is the status a timed-out request reports. It follows GNU
	// timeout(1), so a caller that already knows that convention reads it
	// right.
	//
	// Two ways it is weaker than GNU timeout(1), both measured on nono 0.74.0,
	// 2026-09-20. It does not return AT the timeout: cancellation tears the
	// process groups down then, but the request still pays out whatever of
	// DrainGrace the drains have left, so it returns at the timeout plus up to
	// that grace. Measured with `git log --oneline | git cat-file
	// --batch-check` at a 1000ms timeout, 5 runs (probe log set bt1-5): the 4
	// that hit a stall reported 124 at 2017-2024ms, and the 1 that did not
	// finished 0 at 18ms.
	// And what it kills is what execd started: a policy-controlled command's
	// own children survive it — `sh -c 'sleep 60'` at a 3000ms timeout
	// reported 124 at 5004-5005ms and left the sleep running, 3/3 (log set
	// ss1-3). See Job and DrainGrace for both.
	ExitTimeout     = 124
	ExitCannotStart = 126
	ExitNotFound    = 127
)

The statuses execd reports for outcomes that are not the command's own. They are collected here because a caller reads them as one vocabulary:

the command's own   the last command's exit status
128 + signum        died from a signal
127                 command not found
126                 could not be started (exec or wiring failure)
124                 timed out
2                   syntax error
1                   the interpreter failed for a reason that is not an exit status

An error frame is never used for any of these: it reports a fault of execd itself, and a command that fails is not a fault of execd.

View Source
const DrainGrace = 2 * time.Second

DrainGrace bounds how long a finished command's output is waited for once its process tree is gone. Exceeding it means something outside the group still holds a write end; the request ends with a truncation note rather than hanging, which is what the old wiring did.

This is the bound a pipeline with a policy-controlled command on both ends now runs into, and it is why this package no longer refuses that shape outright. Measured on nono 0.74.0 against this repository's command-profile.json, 2026-09-20, with `git log --oneline | git cat-file --batch-check` — the writer exits, the reader blocks on stdin — run 21 times. Sixteen of those carried a 5000ms request timeout and five carried 20000ms; both are far above this grace, so the grace is the only bound in play either way. Probe log set: runB1-6, full, full2, bb1-10, plus three runs that reached the transcript only.

14 runs   the writer's drain never saw EOF: something on nono's side still
          held a duplicate of the write end, so waitDrains gave up and its
          forced close is what released the blocked reader. Requests ended
          at 2018-2023ms.
 7 runs   no stall at all; requests ended at 15-20ms.

Only 9 of those 14 recorded per-stage timings — runs 1-6 of the set predate the instrumentation — and over those 9 the writer's own Wait returned in 14-16ms, waitDrains gave up at 2000-2001ms, and the reader's Wait then returned at 2018-2020ms. The stalled/clean split above needs no timing line, being read off elapsed time alone, which is why its denominator is 14 and this one is 9.

All 21 returned. Three other aggregates have smaller denominators, because not every run recorded every figure: 18 of the 21 have a byte count, and all 18 are identical at 30585 bytes; 16 had a survivor check afterwards, and all 16 were clean. The remaining runs were not measured for those, which is not the same as having been measured clean.

Three further run sets, each separate from those 21 and from each other. `git log --oneline | cat | git cat-file --batch-check`, a floor command between the two policy stages, 5 runs at a 5000ms timeout (log set cc1-5): 3 stalled, ending at 2023-2029ms, and 2 clean at 15-16ms, all complete. `git log --oneline | cat` — a policy writer into a floor reader — 8 runs (log set dd1-8): 14-19ms, 27361 bytes, and not one stall. That last set is the discriminator: the same writer stalls above and never stalls there, so what the stall takes is a policy-controlled *reader*, not merely piping a shim's output somewhere. The third set is the timeout leg (log set bt1-5), cited on ExitTimeout, and is deliberately not folded in here: those runs ended on a timeout mid-flight and cannot support a claim about complete output.

Every figure above names the log set it came from, and that is deliberate. Three successive revisions of this comment carried numbers belonging to a neighbouring set — the 2028-2029ms pair above is exactly what leaked into the 21-run range twice. If you change a number here, re-derive it from the named set rather than from this comment's previous wording; docs/superpowers/probes/2026-09-19-policy-pipe-hang.md carries the per-run tables each log set above is named for.

So the hazard is unchanged — the fd is still leaked, inside nono's own machinery — but it is a bounded incident now rather than a permanent one.

What this bound no longer reaches is the outer boundary, and that changed with descriptor passing. A top-level simple command's cmd.Stdout is now the caller's own file, so nothing is interposed there and waitDrains has no read end to force-close. A descendant that outlives Job.Terminate — `sh -c 'sleep 60'` does, 3/3, inside nono's child sandbox; see the Job doc above — therefore holds a duplicate of the *caller's* pipe, which this package cannot touch. Pre-branch it held execd's interposed pipe instead, and this grace force-closing that pipe is why those runs returned at 5004ms. Post-branch the request itself returns sooner, ~3000ms, the timeout alone; but `agent-sandbox exec … | downstream` leaves `downstream` waiting for an EOF for as long as the strand lives, and nothing here bounds that.

Accepted, not overlooked. It is inherent to handing a descriptor to a command — ssh has the same shape — and the only way to bound it again is to re-interpose a pipe at the boundary, which is the relay this branch removed. The shape that produces it is narrow and nameable: a command that leaves a descendant running past its own exit, with the caller's stdout a pipe rather than a terminal or a file. What stays bounded is the request; what is not bounded is whoever reads the other end of the caller's pipe.

What that split does and does not say about this constant's value. EOF either arrived within ~15ms or had not arrived by 2000ms; nothing landed in between. So shortening the grace would not make stalls rarer or commoner — it would only make each one cheaper. The hazard in shortening it is elsewhere: waitDrains ends a stall by force-closing the read ends, so a producer still writing at that moment loses its tail. That did not bite in any of the 18 runs that have a byte count — the producer had exited ~2s earlier and its bytes were already in the pipe — but a grace short enough to land while output is still in flight would truncate for real. The note saying so does reach the caller (below), and is the only thing that would tell them; revisions of this comment before 2026-09-21 argued from the opposite premise, so do not resurrect it. Raising the grace lengthens every stalled request by the same amount. Re-measure before moving it either way.

That note is the last thing to know here, and what this comment said about it until 2026-09-21 was false. It claimed the note never reaches the caller from a non-final pipeline stage, because mvdan.cc/sh drops such a stage's stderr entirely, so a policy-to-policy pipe presents as a silent ~2s stall. The drop is real but belongs to the harness that measured it: 2026-09-19 drove ShellExecutor.Execute in-process with a bytes.Buffer as stderr. Through the client, where stderr is a real descriptor, the note arrives — 80 bytes, one distinct text, in 69 of 69 stalled runs across three log sets, on this branch and on the pre-branch binary alike (docs/superpowers/probes/2026-09-21-fd-passing.md §8, which is never committed, so this sentence is where that correction survives). A user of `agent-sandbox exec` therefore sees a ~2s stall, the note naming the command that caused it, complete output, and — unless the request timeout fired first — a correct exit status.

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

SandboxNotRunningHint is the actionable message shown when execd 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_EXECD_SOCKET is unset, and must print this rather than a raw dial error.

View Source
const SocketEnvVar = "AGENT_SANDBOX_EXECD_SOCKET"

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

View Source
const TerminateGrace = 200 * time.Millisecond

TerminateGrace is how long a process gets to act on SIGTERM before SIGKILL. It is long enough for a handler to run and short enough that a cancelled request still returns promptly. Terminate does not spend it when nothing is alive to spend it on.

Variables

View Source
var ErrExecdUnavailable = errors.New("exec daemon is not available")

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

Functions

func SendStdio

func SendStdio(uc *net.UnixConn, s Stdio) error

SendStdio passes the trio to the peer. It must be called before the request: a request that arrives without descriptors is refused.

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.

func WriteSignal

func WriteSignal(w io.Writer, sig syscall.Signal) error

WriteSignal writes a signal frame. It refuses a signal outside the deliverable set rather than sending a frame the peer will reject.

Types

type Channel

type Channel byte

Channel identifies which stream a frame belongs to.

const (
	ChanExit  Channel = 4
	ChanError Channel = 5
	// ChanSignal carries a signal from the client to a running command. It is
	// the only way to interrupt a command short of dropping the connection,
	// which is a SIGKILL in effect. That is the rationale for both the
	// allow-list below and the two-stage interrupt in cmd/exec.go: a client
	// that wants a command to stop politely has this frame and nothing else.
	ChanSignal Channel = 7
)

type Client

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

Client dials the execd 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 ErrExecdUnavailable when the variable is unset, which happens whenever a command is routed to the sandbox outside an `agent-sandbox claude` session.

func (*Client) RunCommand

func (c *Client) RunCommand(ctx context.Context, command string,
	stdio Stdio, opts RunOptions) (int, error)

RunCommand sends one command line to execd and returns its exit status.

The command runs on stdio: those three files are passed to execd over the socket and the command writes through them directly, so nothing of the command's output travels on this connection. All three must be real files — a caller with no input to send opens os.DevNull — because Stdio has no branch for an absent one.

This connection carries what is left: the request, a signal the caller forwards, and the exit status. It is also the request's lifeline — dropping it is how execd learns the caller is gone — which is why it stays open for the whole call even though no bytes of the command flow on it.

type Executor

type Executor interface {
	Execute(ctx context.Context, req Request, stdio Stdio) (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.

func (Frame) Signal

func (f Frame) Signal() (syscall.Signal, bool)

Signal decodes a signal frame. The second result is false for a payload that is not exactly one deliverable signal number.

type Job

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

A Job is one request. It owns the process groups that request created, and teardown is the same path whatever triggered it: the interpreter returning, the connection dropping, the timeout firing, or the client asking.

One group per command, not one per request. A group's id is its leader's pid and the group ceases to exist when its last member exits, so a per-request group led by the request's first command would be empty by the time the second command tried to join it, and setpgid would fail with ESRCH. Keeping it alive would need an anchor process, and an anchor costs one policy launch per request — the exact resource launchPacer rations.

Group membership is inherited across fork, which is what lets one killpg reach a command's grandchildren. It is not a hierarchy: a process that calls setpgid or setsid leaves, so a program that daemonises itself escapes. That is the accepted limit; the alternatives (walking /proc, cgroups, PID namespaces) are respectively racy and not available on macOS.

A policy-controlled command's own children are on the far side of that limit. Measured on nono 0.74.0, 2026-09-20: `sh -c 'sleep 60'` under a 3000ms request timeout reports 124 and the sleep survives the session and keeps running — 3/3 runs, one stranded process each, still running after the session was gone (probe log set ss1-3). The 124 did not arrive at 3000ms: those runs returned at 5004-5005ms, because the request still pays DrainGrace on the way out (see ExitTimeout for that bound). The identical line as a floor command, `sleep 60` with no policy command in it, is killed at the timeout and leaves no survivor, 3/3, returning at 3002-3003ms with no drain to wait for (log set fs1-3). Piping the policy command from another one changes nothing: `git log --oneline | sh -c 'cat >/dev/null; sleep 60'` strands identically, 3/3 (log set lp1-3). So what escapes is whatever nono's shim puts between this process and the real command, not teardown failing in general. It has nothing to do with pipelines: the same strand happens with no pipe in the line at all.

func NewJob

func NewJob(stdio Stdio) *Job

NewJob returns a Job for a request running on stdio. The trio is kept so the wiring can tell the request's own files from the interpreter's: a descriptor the client passed may be a pipe, and a pipe the interpreter made may not be given to a child, so the question can only be settled by identity.

func (*Job) Signal

func (j *Job) Signal(sig syscall.Signal)

Signal delivers sig to every group this job still has members in.

func (*Job) Start

func (j *Job) Start(cmd *exec.Cmd) error

Start puts cmd in a process group of its own and starts it.

func (*Job) Stdio

func (j *Job) Stdio() Stdio

Stdio returns the request's own three files.

func (*Job) Terminate

func (j *Job) Terminate()

Terminate ends everything execd itself started: SIGTERM, a grace period, then SIGKILL for whatever ignored it. It returns at once when no group has a live member, which is the ordinary case — a command that finished cleanly leaves nothing behind, and every request would otherwise pay the grace period.

"Everything execd started" is the whole guarantee, and it stops at nono's shim: a policy-controlled command's own children run inside nono's child sandbox, outside the groups this Job created, and killpg does not follow them there. Measured — see the Job doc comment above.

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"`
	// TimeoutMs bounds the whole request. Zero means no bound: the caller's own
	// timeout (the harness's, for an agent's command) is the only one.
	TimeoutMs int `json:"timeout_ms"`
}

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

It carries a command *line*, not an argv. execd 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: execd'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.

Before adding a field here, check whether a mismatch on it would be caught structurally. It is today only because this protocol's first move is a descriptor handshake an older binary can neither send nor read; that is a property of this protocol, not a general one. If a new field could be dropped silently by a peer that does not know it, bring back a version field with it.

func ReadRequest

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

ReadRequest reads a request written by WriteRequest.

type RunOptions

type RunOptions struct {
	// TimeoutMs bounds the request; zero means no bound.
	TimeoutMs int
	// Signals, when non-nil, is drained for the life of the request and each
	// signal is forwarded to the command.
	Signals <-chan syscall.Signal
}

RunOptions carries the per-request knobs that are not the command itself.

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 execd 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 execd 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.startExecd, which runs `nono run --profile <command profile> -- agent-sandbox execd --socket <path>` (see claude.ExecdArgs) 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

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: execd 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 execd's own sandbox, not by the agent's.

func NewShellExecutor

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

func (e *ShellExecutor) Execute(ctx context.Context, req Request,
	stdio Stdio) (int, error)

Execute satisfies Executor so the server can run a request directly. The server owns the transport; ShellExecutor owns the shell language. It is ExecuteWithSignals with no signal channel, so the contract — the request shape it refuses, the timeout it honours, the Job it tears down — is that function's and executeWithJob's, and is documented there.

func (*ShellExecutor) ExecuteWithSignals

func (e *ShellExecutor) ExecuteWithSignals(ctx context.Context, req Request,
	stdio Stdio, sigs <-chan syscall.Signal) (int, error)

ExecuteWithSignals is Execute plus in-flight signal delivery: every signal read from sigs is delivered to the request's process groups — not to one pid, because a pipeline has a group per stage and a command's own children are in its group. A nil channel means no signals, which is exactly Execute.

The relay goroutine cannot outlive the request: its only exits are the stop channel and the caller closing sigs, and the deferred wait below blocks until it has actually returned. Because defers run last-registered-first, that wait completes before this function's own job.Terminate, so no relayed signal is ever in flight during or after the teardown that ends the request.

That is the only teardown this ordering covers. execHandler starts a watcher that calls job.Terminate when the context is cancelled, and that watcher runs independently of this function and may outlive Run by up to TerminateGrace, so a relayed signal can run concurrently with that teardown. It is safe rather than ordered: Job guards its own state with a mutex, both paths reach the same groups through the same liveness probe, and the worst outcome is a SIGTERM landing beside the one Terminate is already sending. It does not widen the pid-recycling window liveGroups documents — the relay stops existing before this request stops tracking its groups.

func (*ShellExecutor) Run

func (e *ShellExecutor) Run(ctx context.Context, command, cwd string,
	stdio Stdio) (int, error)

Run evaluates command with cwd as the working directory, writing output directly to the caller's own stdout and stderr descriptors, 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 execd itself.

type Stdio

type Stdio struct {
	In, Out, Err *os.File
}

Stdio is the trio of descriptors a request runs on. It is passed across the socket rather than relayed byte by byte: the command writes to the caller's own files, so there is nothing to frame, nothing to drain, and no EOF for this side to wait on.

All three are always present. A caller with no stdin opens /dev/null and passes that; substituting one here would put a branch in the one place the design wants none.

A blocked read on In cannot be interrupted

Known, measured, and deliberately not repaired in code. Every descriptor that reaches RecvStdio is in blocking mode: SendStdio takes each file's number with (*os.File).Fd, and Fd puts the open file description back into blocking mode before returning. os.NewFile on a blocking fd builds a file the runtime poller does not register, and SetReadDeadline on such a file returns "file type does not support deadline" rather than arming anything.

mvdan.cc/sh cancels a blocked standard input read only through that deadline — interp.Runner.readLine arms it from the context — and interp.StdIO's own doc warns about exactly this, right after the paragraph on passing an *os.File: an os.Pipe "has the best chance to support cancellable reads". A descriptor that arrived over SCM_RIGHTS is not one.

So a command parked in a read on In returns when that read returns and at no other time: not when the request's TimeoutMs fires, not when the client drops the connection. runner.Run does not return, so ShellExecutor.Run does not, so the server's handle does not — the handler goroutine, the connection and all three of these descriptors are held until the byte or the EOF arrives. Measured 2026-09-21 through the real client: `read x` with TimeoutMs 700 against a pipe nobody writes to had not returned after 4s, and returned ExitTimeout the instant the write end was closed. TestPassedStdinCannotBeInterruptedWhileBlockedOnARead pins that shape.

Who is exposed. Anything whose stdin is a terminal or a live producer: a human running `agent-sandbox exec`, and `producer | agent-sandbox exec …`. The agent's hook path is not — the harness gives it /dev/null, which reads EOF at once — which is why this is a sharp edge rather than an outage.

Why no fix here. Clearing O_NONBLOCK's absence on the received descriptor would mutate the open file description the client and every child share, which is not execd's to mutate. Giving the interpreter a pipe of execd's own while children keep the real descriptor would break, for stdin only, the identity rule wiring.passthrough is built on. Both are design decisions about what this protocol passes, not repairs to this code.

func RecvStdio

func RecvStdio(uc *net.UnixConn) (Stdio, error)

RecvStdio reads the handshake and returns the three descriptors it carried.

func (Stdio) Close

func (s Stdio) Close()

Close closes every file in the trio. The receiver owns what it received and must call this when the request ends: while execd holds a copy, a caller that passed the write end of a pipe never sees EOF.

Jump to

Keyboard shortcuts

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