Documentation
¶
Overview ¶
Package native implements container.Backend by running commands directly on the host via os/exec. It ignores the IR's image: field and refuses compose: containers. Native runs are resumable — snapshot: workspace workdirs are restored from a full gzip-tar archive on resume; the host base environment is not pinned, so native remains explicitly out-of-spec for digest-pinned reproducibility.
Design: docs/superpowers/specs/2026-04-20-awf-phase4-slice-4-7-design.md
/tmp/awf bootstrap: New() creates host /tmp/awf because the engine dispatcher writes AWF_OUTPUT to /tmp/awf/<step>.json (engine/awf_output.go:24-26) and the author's shell step `... > $AWF_OUTPUT` requires the directory to exist. Docker doesn't need this (each container's /tmp is fresh).
CaptureFiles `..` traversal: resolves via filepath.Join's cleaning rules. A path like ../etc/passwd reads from the host literally. Consistent with the no-isolation design (Cmd.Run is equally unconstrained).
Index ¶
- func ApplyLandlock(roDirs, rwDirs []string) error
- func MaybeRunSandboxTrampoline()
- type Backend
- func (b *Backend) Capabilities() container.Caps
- func (b *Backend) CaptureFiles(ctx context.Context, h container.Handle, paths []string) ([]container.CapturedFile, error)
- func (b *Backend) CopyTo(ctx context.Context, h container.Handle, files []container.InputFile) error
- func (b *Backend) Create(ctx context.Context, spec container.ContainerSpec) (container.Handle, error)
- func (b *Backend) Destroy(ctx context.Context, h container.Handle) error
- func (b *Backend) Exec(ctx context.Context, h container.Handle, cmd container.Cmd) (<-chan container.IOChunk, <-chan container.ExecResult, error)
- func (b *Backend) Restore(ctx context.Context, ref container.SnapshotRef, name string) (container.Handle, error)
- func (b *Backend) SandboxWarnLabel() string
- func (b *Backend) Snapshot(ctx context.Context, h container.Handle) (container.SnapshotRef, error)
- type Option
- type SandboxPolicy
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
func ApplyLandlock ¶
ApplyLandlock enforces a Landlock FS policy in the current process. Called by the __sandbox trampoline (native.MaybeRunSandboxTrampoline) BEFORE syscall.Exec.
Policy:
- RODirs: read-only access (no write, rename, or truncate).
- RWDirs: full read+write access.
- IgnoreIfMissing: silently skips paths that do not exist on the host (newly-provisioned machines may lack optional dirs like /lib64).
Fail-closed contract: we refuse to run a "sandboxed" step on a kernel with NO Landlock support (ABI 0) — silently executing on the unrestricted host would violate the faithful-delivery guarantee (promised isolation we can't provide = loud failure, never silent host execution). But the confinement we need is only FS path restriction (RODirs/RWDirs), which EVERY Landlock ABI (v1+) enforces. So we apply with BestEffort, which downgrades from the newest ABI to whatever the running kernel actually supports and still restricts the filesystem. (Hard-pinning a specific newest ABI like V9 would wrongly reject every kernel between v1 and v8, even though they can fully enforce our policy — the CI runner is exactly such a kernel.) The explicit ABI>=1 guard ensures BestEffort never degrades to a silent no-op on a Landlock-less kernel.
Note: RestrictPaths also sets the "no new privileges" flag in the calling process, which is appropriate for untrusted step execution.
func MaybeRunSandboxTrampoline ¶
func MaybeRunSandboxTrampoline()
MaybeRunSandboxTrampoline inspects os.Args; if this process was re-exec'd as "<self> __sandbox <policyJSON> -- sh -c <run>" it applies the Landlock policy and replaces the process with /bin/sh running the step command (it never returns). Otherwise it returns immediately and the caller continues normally (the common case).
Types ¶
type Backend ¶
type Backend struct {
// contains filtered or unexported fields
}
Backend implements container.Backend by spawning host processes via os/exec. Single concrete impl; not safe for concurrent runs of the same workflow (see slice 4.7 design spec Appendix E for the /tmp/awf/<step>.json clobber failure mode).
func New ¶
New constructs a Backend with workdirRoot as the parent for per- container workdirs. Also bootstraps host /tmp/awf (idempotent). Validates only nil/empty workdirRoot — filesystem errors surface lazily from Create's MkdirAll, matching docker's pattern.
Edge case: pre-existing /tmp/awf with restrictive perms (e.g., root-owned 0o700) is NOT detected here — first Exec fails with shell "permission denied" instead.
func (*Backend) Capabilities ¶
Capabilities reports SnapshotFSArchive iff blobs were supplied (WithBlobs), else SnapshotNone — native implements Snapshot/Restore only against an injected CAS store. Without it, workflows with snapshot:workspace containers must use --backend docker.
func (*Backend) CaptureFiles ¶
func (b *Backend) CaptureFiles(ctx context.Context, h container.Handle, paths []string) ([]container.CapturedFile, error)
CaptureFiles reads the named in-container paths and returns their content. Relative paths resolve via filepath.Join against the handle's workdir; absolute paths are used literally on the host filesystem (no chroot — consistent with the no-isolation design, decision 10). Missing-path is a hard error (no partial returns) matching docker's CaptureFiles behavior.
CaptureFiles `..` traversal: filepath.Join cleans `..` segments, so `CaptureFiles(h, []string{"../etc/passwd"})` resolves to `<host>/etc/passwd`. This is expected behavior — Cmd.Run is equally unconstrained.
func (*Backend) CopyTo ¶
func (b *Backend) CopyTo(ctx context.Context, h container.Handle, files []container.InputFile) error
CopyTo writes each InputFile to the host filesystem — the symmetric inverse of CaptureFiles. Path resolution matches CaptureFiles: absolute paths are used literally on the host (no chroot — the no-isolation design, decision 10), relative paths resolve via filepath.Join against the handle's workdir. Parent directories are created (0o755) so a nested destination "just works", mirroring docker's auto-mkdir. Overwrites an existing file. len(files)==0 is a no-op; unknown handle is a hard error. Never touches state.Blobs.
`..` traversal: filepath.Join cleans `..` segments, so a destination like "../etc/foo" writes to the host literally. Consistent with CaptureFiles and Cmd.Run, which are equally unconstrained.
func (*Backend) Create ¶
func (b *Backend) Create(ctx context.Context, spec container.ContainerSpec) (container.Handle, error)
Create rejects compose-mode (no service-routing on host), ignores spec.Image (native is not digest-pinned per decision 1), and creates <workdirRoot>/<spec.Name>/ as the per-container workdir. The Handle's ID carries the workdir path so Exec/CaptureFiles/Destroy can resolve it via the handles map.
func (*Backend) Destroy ¶
Destroy removes the handle's workdir and unregisters the handle. Returns an error on double-destroy (matches docker / os.File.Close convention). os.RemoveAll is idempotent on ENOENT, so a workdir that's already gone (e.g., a step deleted it) succeeds.
func (*Backend) Exec ¶
func (b *Backend) Exec(ctx context.Context, h container.Handle, cmd container.Cmd) (<-chan container.IOChunk, <-chan container.ExecResult, error)
Exec runs cmd.Run via `sh -c` with cmd.Dir = handle workdir. Host env is inherited and merged with cmd.Env (cmd.Env wins on conflict). Stdout is accumulated into ExecResult.Stdout; stderr surfaces only via the IOChunk channel (per AWF spec §4.1 — implicit outputs are exit_code and stdout only).
Streaming contract (slice 5.3): returns (chunks, result, error). Per-pipe reader goroutines emit IOChunks live as bytes arrive; a waiter goroutine wg.Waits both readers, then c.Wait()s the process, closes chunks, computes the ExitCode and emits ONE ExecResult on the 1-buffered result channel. chunks closes BEFORE result emits — every chunk is materialized before the result is observable.
ctx-cancel: Go 1.20+ exec.CommandContext sets Cmd.Cancel = cmd.Process.Kill by default. ctx.Done() -> SIGKILL -> pipes close -> reader goroutines exit -> wg.Wait returns. Pre-slice-5.3 the ctx.Err came back via Backend.Exec's err return; the streaming refactor surfaces it via ExecResult.Err instead so the result channel can carry it without short-circuiting the chunk drain. c.Wait's error is discarded; ProcessState.ExitCode() is the meaningful signal (matches docker's ContainerExecInspect pattern and pre-5.3 native behavior).
Phase 2 + slice 5.3 contract: on non-nil err return, BOTH channels are nil (callers MUST check err before receiving on either).
func (*Backend) Restore ¶
func (b *Backend) Restore(ctx context.Context, ref container.SnapshotRef, name string) (container.Handle, error)
Restore re-materializes a container workdir from a SnapshotRef. EVERY filesystem op goes through one os.Root rooted at the (trusted, fixed) workdir, so a '..' name or an attacker-planted/captured symlink cannot redirect a write outside the workdir. The confinement is enforced by os.Root at whichever op the malicious path hits first — the parent MkdirAll (e.g. "evil/x" after an "evil"->outside symlink trips EEXIST at ensureParent) or the create/symlink itself (TOCTOU-safe at openat; the same primitive loader.go uses). Perms are set AT CREATE (no post-create Root.Chmod) to avoid CVE-2026-32282.
func (*Backend) SandboxWarnLabel ¶
SandboxWarnLabel returns a non-empty warning string when the backend was constructed with WithSandbox(true) but no platform sandbox was available (i.e. the no-op fallback is in use). Returns "" when sandbox is disabled or when a real platform launcher was selected.
The CLI MUST print this to stderr immediately after constructing the native backend, in the same style as the no-image warning at cli/run.go:354:
awf run: --backend native: <SandboxWarnLabel()>
func (*Backend) Snapshot ¶
Snapshot captures the container workdir as a deterministic gzip-tar blob. Enforces TWO caps: the compressed-output cap (cappedWriter on the gzip stream) and the decompressed-total cap (uncompressed bytes fed in) — the latter symmetric with Restore so an unrestorable snapshot cannot be created.
type Option ¶
Option configures a native Backend at construction.
func WithBlobs ¶
WithBlobs supplies the CAS store Snapshot/Restore use. Without it the backend advertises SnapshotNone and Snapshot returns a descriptive error.
func WithSandbox ¶
WithSandbox enables (enabled=true) or disables (enabled=false) OS-level process sandboxing for native backend Exec calls. When enabled, detectSandbox resolves the best available platform launcher (bwrap on Linux, sandbox-exec on macOS). If no platform sandbox is available the backend falls back to a no-op launcher and SandboxWarnLabel returns a non-empty loud warning string that the CLI MUST surface to stderr (mirror of cli/run.go:354 pattern).
When disabled (or not supplied), sandbox detection is skipped entirely and SandboxWarnLabel returns "".
func WithSnapshotMaxBlobBytes ¶
WithSnapshotMaxBlobBytes overrides the compressed snapshot-blob cap (default snapshotDefaultMaxBlobBytes). n must be > 0.
func WithSnapshotMaxRestoreBytes ¶
WithSnapshotMaxRestoreBytes overrides the cumulative decompressed-bytes cap (default snapshotDefaultMaxRestoreBytes). n must be > 0.
type SandboxPolicy ¶
type SandboxPolicy struct {
RODirs []string `json:"ro_dirs"`
RWDirs []string `json:"rw_dirs"`
Run string `json:"run"`
}
SandboxPolicy is the JSON payload the __sandbox trampoline subcommand decodes. It is exported so cmd/awf/sandbox_linux.go can decode it without duplicating the struct definition.