native

package
v0.4.0 Latest Latest
Warning

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

Go to latest
Published: Jul 7, 2026 License: Apache-2.0 Imports: 21 Imported by: 0

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

$AWF_OUTPUT (U3/F25): Create pre-creates <workdir>/.awf/output (workdir- relative, per Caps.OutputRoot) because the engine dispatcher writes AWF_OUTPUT there (engine/awf_output.go) and the author's shell step `... > $AWF_OUTPUT` requires the directory to exist. Workdir-relative (rather than a process-global host path) so the Linux sandbox's real bind-mount of the workdir — not its tmpfs-over-/tmp — is what backs the write. 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

Constants

This section is empty.

Variables

This section is empty.

Functions

func ApplyLandlock

func ApplyLandlock(roDirs, rwDirs []string) error

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 <workdir>/.awf/output/<step>.json clobber failure mode).

func New

func New(workdirRoot string, opts ...Option) (*Backend, error)

New constructs a Backend with workdirRoot as the parent for per- container workdirs. Validates only nil/empty workdirRoot — filesystem errors surface lazily from Create's MkdirAll, matching docker's pattern.

$AWF_OUTPUT (U3/F25): unlike the pre-U3 design, New no longer bootstraps a process-global host directory — Create pre-creates <workdir>/.awf/output per container instead (see Create), so AWF_OUTPUT lands under the per-container workdir rather than a shared host path.

func (*Backend) Capabilities

func (b *Backend) Capabilities() container.Caps

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

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

func (b *Backend) Destroy(ctx context.Context, h container.Handle) error

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) ReadFileAt added in v0.1.1

func (b *Backend) ReadFileAt(ctx context.Context, h container.Handle, filePath string) ([]byte, error)

ReadFileAt reads the file at path inside the handle's workdir. Confinement goes through os.OpenRoot rooted at the workdir — TOCTOU-safe, matching the extractInto pattern in snapshot.go. The cleaned path must resolve to a file; a missing path is a hard error.

Confinement is two-layered: the path is first normalized (leading slash stripped, then path.Clean) so ".." components collapse and cannot climb out, and os.Root then follows symlinks that stay within the workdir but refuses any symlink that would resolve outside it. So neither a ".." path nor a malicious symlink escapes the root. Following an intra-root symlink is intentional — the captured artifact is the agent's own session transcript, which legitimately lives under the workdir.

func (*Backend) ReadTreeAt added in v0.1.1

func (b *Backend) ReadTreeAt(ctx context.Context, h container.Handle, dir string) ([]byte, error)

ReadTreeAt captures the subtree rooted at dir (workdir-relative) as a deterministic gzip-tar. Entry paths in the archive are relative to dir — matching the convention used by the Fake backend and BuildTreeTar.

Confinement: os.OpenRoot(r.workdir) is used for all filesystem operations. dir is normalised via path.Join(".", path.Clean("/"+dir)) so ".." components collapse before the root is opened; os.Root then refuses any symlink that resolves outside the workdir at read/stat time. Both layers together make escapes impossible regardless of the workdir contents.

Returns an error when dir does not exist inside the handle's workdir.

func (*Backend) ResolveWorkdirPath added in v0.1.1

func (b *Backend) ResolveWorkdirPath(h container.Handle, rel string) string

ResolveWorkdirPath implements container.WorkdirResolver. It returns filepath.Join(workdir, rel) where workdir is the absolute host path of the handle's working directory. If the handle is unknown (defensive), rel is returned unchanged — the method never panics.

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) SandboxMode added in v0.3.0

func (b *Backend) SandboxMode() string

SandboxMode returns the resolved sandbox status token (F30): "none" when sandboxing was not requested (WithSandbox(false) or not supplied) or when it was requested but no platform sandbox was available (the no-op fallback); otherwise the real launcher's label — "bwrap", "landlock-trampoline", or "sandbox-exec". Always non-empty. A pure getter over state resolved at construction time — no exec calls.

Complements SandboxWarnLabel: that method returns "" in every case except the requested-but-unavailable one (where it carries a loud warning string); this one is a concise, always-populated token meant for the CLI's run-start status line (cli/run.go), independent of whether a warning fires.

func (*Backend) SandboxWarnLabel

func (b *Backend) SandboxWarnLabel() string

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.

func (*Backend) WriteFileAt added in v0.1.1

func (b *Backend) WriteFileAt(ctx context.Context, h container.Handle, filePath string, content []byte) error

WriteFileAt writes content to path inside the handle's workdir, creating parent directories as needed. Confinement goes through os.OpenRoot rooted at the workdir — TOCTOU-safe, matching the extractInto pattern in snapshot.go. Overwrites any existing file.

Confinement is identical to ReadFileAt — path normalization collapses ".." components and os.Root refuses any symlink that resolves outside the workdir, so a write cannot escape the root.

func (*Backend) WriteTreeAt added in v0.1.1

func (b *Backend) WriteTreeAt(ctx context.Context, h container.Handle, dir string, tarGz []byte) error

WriteTreeAt extracts a gzip-tar (as produced by ReadTreeAt or BuildTreeTar) into dir (workdir-relative), creating dir and its parents if needed. Existing files are overwritten. Entry paths in the archive are relative to dir. Honors b.snapshotMaxRestoreBytes and b.maxEntries as zip-bomb guards.

Confinement is identical to ReadTreeAt — os.OpenRoot(r.workdir) plus the same dir normalisation. The handle is validated BEFORE any tar decoding. A tar entry whose follow-through traverses an escaping symlink is refused by os.Root at MkdirAll/OpenFile time.

type Option

type Option func(*Backend) error

Option configures a native Backend at construction.

func WithBlobs

func WithBlobs(b state.Blobs) Option

WithBlobs supplies the CAS store Snapshot/Restore use. Without it the backend advertises SnapshotNone and Snapshot returns a descriptive error.

func WithSandbox

func WithSandbox(enabled bool) Option

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

func WithSnapshotMaxBlobBytes(n int64) Option

WithSnapshotMaxBlobBytes overrides the compressed snapshot-blob cap (default snapshotDefaultMaxBlobBytes). n must be > 0.

func WithSnapshotMaxRestoreBytes

func WithSnapshotMaxRestoreBytes(n int64) Option

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.

Jump to

Keyboard shortcuts

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