exec

package
v0.2.0 Latest Latest
Warning

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

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

Documentation

Overview

Package exec provides command execution utilities for Linux system management.

It wraps the go-cmd library to provide buffered and streaming command execution with privilege escalation, context cancellation, and output truncation.

Command execution goes through the injected Runner: build one with NewRunner(Sudo|Doas|Direct) (Detect lists the escalation tools available on the host) and pass it to a capability constructor. The Runner carries no global state and is unit-testable with exectest.FakeRunner. The legacy process-global path (Run*/Privileged*/Query* and the SetPrivilegeBackend global) was removed once every capability migrated onto the Runner.

Index

Constants

View Source
const EndOfOptions = "--"

EndOfOptions is the POSIX "--" token. Everything after it on a command line is treated as an operand, never an option.

View Source
const MaxOutputBytes = 1 << 20

MaxOutputBytes is the maximum number of bytes captured per output stream.

Variables

View Source
var (
	// ErrUnknownBackend is returned by NewRunner for the zero value or any
	// PrivilegeBackend the SDK does not implement (fail-closed, no silent
	// default escalation).
	ErrUnknownBackend = errors.New("unknown privilege backend")

	// docref: begin escalation-sentinels
	// ErrEscalationUnavailable is returned when the chosen escalation tool
	// (sudo/doas) is not installed on this host.
	ErrEscalationUnavailable = errors.New("escalation tool not installed")

	// ErrEscalationDenied is returned when `sudo -n` / `doas -n` would need a
	// password (no NOPASSWD rule) — the agent never has a terminal to type one,
	// so this fails closed rather than hanging.
	ErrEscalationDenied = errors.New("escalation requires a password")

	// ErrRunnerRequired is returned by a capability constructor (New) when the
	// caller passes a nil Runner. It is shared by every capability package so a
	// nil runner is rejected identically everywhere and callers can match it with
	// errors.Is regardless of which capability they constructed.
	ErrRunnerRequired = errors.New("runner is required")

	// ErrBackendUnavailable is returned when the command/tool a capability needs
	// is not available on this host — the Runner cannot resolve the binary on
	// PATH. It is errors.Is-matchable so a caller can treat "tool not installed"
	// distinctly from a real failure (e.g. IsRequired-style probes report a
	// missing tool as "no, and that's fine" rather than an error).
	ErrBackendUnavailable = errors.New("backend unavailable")
)

Construction- and escalation-failure sentinels. All are errors.Is-matchable so a caller can fail closed and distinguish the cases.

View Source
var BlockedEnvVars = map[string]bool{

	"GCONV_PATH": true,

	"HOSTALIASES":      true,
	"RESOLV_HOST_CONF": true,

	"GETCONF_DIR": true,

	"NODE_OPTIONS": true,
	"PYTHONPATH":   true,
	"PERL5OPT":     true,
	"PERL5LIB":     true,
	"RUBYLIB":      true,

	"PATH":       true,
	"IFS":        true,
	"ENV":        true,
	"BASH_ENV":   true,
	"CDPATH":     true,
	"GLOBIGNORE": true,
}

BlockedEnvVars are environment variable names that must never be overridden because they can hijack process execution (library injection, path manipulation).

The whole LD_*, BASH_FUNC_*, and DYLD_* families are blocked unconditionally by the prefix check in IsAllowedEnvVar, so individual LD_* keys (LD_PRELOAD, LD_LIBRARY_PATH, LD_AUDIT, LD_DEBUG, LD_PROFILE) and the BASH_FUNC_ prefix are not enumerated here — listing them would only duplicate the prefix rule. This map carries the names that have no covering prefix.

View Source
var ErrBlockedEnvVar = errors.New("env var blocked by hijack-prevention allowlist")

ErrBlockedEnvVar is returned when an env entry's KEY is on the hijack-blocklist (LD_PRELOAD, PATH override, BASH_ENV, GCONV_PATH, etc.). Catches CVE-class injections at the SDK boundary so every Command.Env passed to the Runner inherits the check in one place.

View Source
var ErrInvalidEnvVar = errors.New("invalid env entry")

ErrInvalidEnvVar is returned when an env entry in Command.Env is not in the canonical KEY=VALUE form. Surfaced as a programmer error so the bad value isn't silently dropped before the child runs.

View Source
var ErrReservedEnvVar = errors.New("env var reserved by the SDK for deterministic output")

ErrReservedEnvVar is returned when an env entry tries to set a variable the Runner forces for deterministic output (the locale family + NO_COLOR). The SDK parses standardized tool output, so a consumer must not be able to change the locale/color of a command — these are imposed by the Runner, not negotiable.

View Source
var ErrSecretContainsNewline = errors.New("secret contains a newline or carriage return")

ErrSecretContainsNewline is returned by NewSecret when the value contains a newline or carriage return. A credential is frequently piped to a tool's stdin (chpasswd, cryptsetup); an embedded newline would split it into a second record, so it is rejected at construction.

View Source
var ValidEnvVarName = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`)

ValidEnvVarName matches safe environment variable names (letters, digits, underscore).

Functions

func IsAllowedEnvVar

func IsAllowedEnvVar(name string) bool

IsAllowedEnvVar returns true if the environment variable name is safe to set.

func SeparatePositionals

func SeparatePositionals(flags []string, positionals ...string) []string

SeparatePositionals builds an argv slice that places positionals after an explicit EndOfOptions ("--") separator, so a value that happens to be flag-shaped, such as a package name like "-e", can never be reparsed by the invoked program as an option. The "--" is ALWAYS inserted, even when there are no positionals — terminating the option list is harmless and keeps the invariant unconditional (callers never have to reason about the empty case).

The result is a freshly-allocated slice; the caller's flags slice is never aliased or appended into, so a reused flag list stays pristine across calls.

func ValidateCommandEnv

func ValidateCommandEnv(env []string) error

ValidateCommandEnv checks a Command.Env slice with the EXACT rules the real Runner enforces before it spawns any child: each entry must be KEY=VALUE (ErrInvalidEnvVar), the key must not be a hijack-prone name on the blocklist (ErrBlockedEnvVar — PATH/LD_PRELOAD/BASH_ENV/…), and the key must not be a name the Runner reserves for deterministic output (ErrReservedEnvVar — LC_ALL/LANG/NO_COLOR). It is exported so exectest.FakeRunner can apply the identical gate, keeping the unit tier faithful to the real env boundary — an adversarial Command.Env is rejected the same way against a fake as against a real Runner.

Types

type Command

type Command struct {
	Name      string    // resolved to an absolute path before escalation
	Args      []string  // operands; the caller pre-applies SeparatePositionals
	Dir       string    // "" = inherit cwd
	Env       []string  // extra KEY=VALUE; screened by the env hijack blocklist
	Stdin     io.Reader // "" = no stdin
	ChildPath string    // explicit, isolating child PATH; "" = inherit/sanitized
	Escalate  bool      // run through the privilege backend
}

Command describes one execution. The zero value is invalid — Name is required. The capability layer fills this in and sets Escalate per operation; it is escalation-method-agnostic. The Runner alone turns Escalate into the concrete sudo/doas/bare invocation.

There is no locale knob: the Runner ALWAYS forces a deterministic environment (LC_ALL=C, LANG=C, NO_COLOR=1) on every command so the SDK's parsing of tool output is locale/format-stable by construction. It is not overridable — those names are rejected if passed via Env. (TZ is deliberately left to the device.)

type CommandError

type CommandError struct {
	Name     string // the command that failed, e.g. "useradd"
	ExitCode int
	Stderr   string
	Err      error // underlying cause, if any
}

CommandError is the typed error the capability layer wraps a failed command in (Decision 3). The Runner itself does NOT treat a non-zero exit as an error (callers branch on specific codes — cryptsetup 2 = wrong passphrase, etc.); the capability layer decides when a non-zero exit becomes a CommandError. It carries the exit code and captured stderr so callers can branch on them via errors.As without importing internals.

func (*CommandError) Error

func (e *CommandError) Error() string

func (*CommandError) Unwrap

func (e *CommandError) Unwrap() error

type OutputCallback

type OutputCallback func(streamType StreamType, line string, seq int64)

OutputCallback is called for each line of output during streaming execution. streamType is the typed StreamType (Go's type system rejects implicit int↔StreamType conversion, so taking int here would mean every comparison needed an explicit cast). line is the output line including its trailing newline. seq is a stream-local monotonic ordering counter.

type PrivilegeBackend

type PrivilegeBackend int

PrivilegeBackend selects how a Runner escalates privilege for a Command whose Escalate flag is set. The zero value is INVALID (fail-closed): NewRunner rejects it with ErrUnknownBackend. Valid values start at 1 (Decision 5/6).

const (
	// Sudo escalates via `sudo -n` (non-interactive; never prompts).
	Sudo PrivilegeBackend = iota + 1
	// Doas escalates via `doas -n`.
	Doas
	// Direct runs with no wrapper — the process is already root. Rolling our
	// own no-op pass-through avoids sudo's distro-varying "root must be in
	// sudoers" check (opensuse rejects it by default) and the cost of forking
	// sudo just to re-exec the same binary.
	Direct
)

func Detect

func Detect(ctx context.Context) []PrivilegeBackend

Detect lists the privilege-escalation backends usable on THIS host (Decision 6/7): Sudo if `sudo` is on PATH, Doas if `doas` is on PATH. It LISTS — it never picks and never constructs a Runner; the consumer reads the list, picks one explicitly, and passes it to NewRunner. Direct (run as the current process, no wrapper) needs no detection and is never returned. A host with neither tool returns an empty slice — a valid result for a root-only consumer that will use Direct.

The ctx is accepted for signature uniformity with the capability Detect functions (which stat marker files); the present probe is a pure PATH lookup.

type Result

type Result struct {
	ExitCode int
	Stdout   string
	Stderr   string
}

Result holds the output of a command execution.

type Runner

type Runner interface {
	// Run executes c and returns its captured output. A non-zero exit is
	// reported in Result.ExitCode, NOT as an error; a non-nil error means the
	// command could not be executed (binary not found, blocked env var, ctx
	// cancelled) or escalation failed (ErrEscalation*).
	Run(ctx context.Context, c Command) (Result, error)
	// Stream is Run with real-time line delivery via onLine.
	Stream(ctx context.Context, c Command, onLine OutputCallback) (Result, error)
	// Backend reports the privilege backend, so a capability (e.g. fs) can pick
	// its fd-safe vs sudo code path.
	Backend() PrivilegeBackend
}

Runner abstracts command execution + privilege escalation. It is injected into every capability constructor (Decision 2) so the SDK keeps no global escalation state and the whole capability layer is unit-testable with a fake (see exectest.FakeRunner) — no host, no sudo, no container.

func NewRunner

func NewRunner(b PrivilegeBackend) (Runner, error)

NewRunner builds a Runner for the named backend. It is PURE: it validates the backend is known and does NOT probe the host (use Detect for that). The zero value and any unimplemented backend are rejected with ErrUnknownBackend.

type Secret

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

Secret wraps a sensitive string such as a password or private key so it cannot reach a log or formatted string by accident: String, GoString, and the %v/%s/%#v verbs all render "[REDACTED]". The plaintext is retrievable only via Reveal.

func NewMultilineSecret

func NewMultilineSecret(v string) Secret

NewMultilineSecret constructs a Secret WITHOUT the newline rejection NewSecret applies. It is for credentials written verbatim to a FILE — e.g. an EAP-TLS PEM private key — where embedded newlines are part of the payload and there is no stdin record-splitting (or keyfile line-injection) hazard.

NEVER use it for a credential piped to a tool's stdin or interpolated into a line-oriented config (chpasswd, an nmcli keyfile psk= line): there, use NewSecret, whose newline rejection prevents the value from splitting into a second record. The redaction (String/GoString) and Reveal-sink contract are identical; only the construction-time newline check differs.

func NewSecret

func NewSecret(v string) (Secret, error)

NewSecret constructs a Secret, rejecting embedded newlines/CR (see ErrSecretContainsNewline). An empty secret is valid — some callers legitimately clear or omit a key.

func (Secret) GoString

func (s Secret) GoString() string

GoString renders the redaction sentinel for the %#v verb as well.

func (Secret) HasNewline

func (s Secret) HasNewline() bool

HasNewline reports whether the secret contains a newline or carriage return — without exposing the plaintext (it returns only a bool, so it is not a Reveal sink). A NewSecret value never does; a NewMultilineSecret one may, and such a value must not be interpolated into a line-oriented sink (a tool's stdin record, or an nmcli keyfile `psk=` line). Callers validate with this instead of revealing the secret to inspect it.

func (Secret) IsZero

func (s Secret) IsZero() bool

IsZero reports whether the secret is empty.

func (Secret) Reveal

func (s Secret) Reveal() string

Reveal returns the underlying plaintext. This is the ONLY sanctioned sink; every other access path redacts.

func (Secret) String

func (s Secret) String() string

String renders the redaction sentinel so a Secret in a log/format never leaks.

type StreamType

type StreamType int

StreamType identifies which standard stream a line of streaming output came from. The named type lets the compiler reject a stray `int` literal where the contract is "stdout or stderr". Numeric values are stable across SDK releases.

const (
	// StreamStdout is the streamType passed to OutputCallback for
	// stdout lines.
	StreamStdout StreamType = 1
	// StreamStderr is the streamType passed to OutputCallback for
	// stderr lines.
	StreamStderr StreamType = 2
)

Directories

Path Synopsis
Package exectest provides a fake exec.Runner for unit-testing capability packages with no host, no sudo, and no container.
Package exectest provides a fake exec.Runner for unit-testing capability packages with no host, no sudo, and no container.

Jump to

Keyboard shortcuts

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