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
- Variables
- func IsAllowedEnvVar(name string) bool
- func SeparatePositionals(flags []string, positionals ...string) []string
- func ValidateCommandEnv(env []string) error
- type Command
- type CommandError
- type OutputCallback
- type PrivilegeBackend
- type Result
- type Runner
- type Secret
- type StreamType
Constants ¶
const EndOfOptions = "--"
EndOfOptions is the POSIX "--" token. Everything after it on a command line is treated as an operand, never an option.
const MaxOutputBytes = 1 << 20
MaxOutputBytes is the maximum number of bytes captured per output stream.
Variables ¶
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") // 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") // 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.
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.
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.
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.
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.
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.
var ValidEnvVarName = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`)
ValidEnvVarName matches safe environment variable names (letters, digits, underscore).
Functions ¶
func IsAllowedEnvVar ¶
IsAllowedEnvVar returns true if the environment variable name is safe to set.
func SeparatePositionals ¶
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 ¶
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 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 ¶
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 ¶
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) HasNewline ¶
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.
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 )