diff --git a/agent-image/devenv.nix b/agent-image/devenv.nix index 5dbf3777..8546e839 100644 --- a/agent-image/devenv.nix +++ b/agent-image/devenv.nix @@ -68,13 +68,15 @@ in # Identity, matched to the Go runtime rather than devenv's default. The # runtime runs the agent as uid 1000 with $HOME=/home/agent - # (cmd/compass-runner/main.go:48 `-home-dir`, :118 `UID: defaultAgentUID`; - # internal/runner/spec.go:29-31 `SpecDefaults.CheckoutDir/HomeDir/UID`), and - # launches containers with plain `--userns=keep-id` - # (internal/runtime/podman.go:357), which maps the host Runner uid through - # unchanged rather than remapping it — hence the `verifyRunnerUID` guard - # that fails fast when the Runner is not itself uid 1000 - # (cmd/compass-runner/main.go:146-169). devenv defaults to user `user` with + # (cmd/compass-runner/main.go `-home-dir`, `UID: defaultAgentUID`; + # internal/runner/spec.go `SpecDefaults.CheckoutDir/HomeDir/UID`), and + # launches containers with + # `--userns=keep-id:uid=,gid=` + # (internal/runtime/podman.go createArgs), which remaps the invoking host + # uid to the baked agent uid rather than passing it through — so an + # arbitrary host uid still yields an agent that owns /nix. A startup + # podman-version preflight (VerifyUsernsRemapSupport, ≥ 4.3) guards that + # the engine supports the remap. devenv defaults to user `user` with # $HOME=/env; the uid agrees either way — that is what /nix ownership keys # on — but the passwd row and $HOME must match what the Runner execs with, # or nix/direnv/devenv hit "$HOME is not owned by you" and silently fall diff --git a/go/cmd/compass-runner/main.go b/go/cmd/compass-runner/main.go index 3f8d7309..fd5e9a24 100644 --- a/go/cmd/compass-runner/main.go +++ b/go/cmd/compass-runner/main.go @@ -86,11 +86,15 @@ func run() error { slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, nil))) log := slog.Default() - // Ahead of every operator-input check: this validates the process's own - // identity, takes no configuration, and its failure is unconditional. Behind - // the flag checks, an operator on the wrong uid is told to set a token, fixes - // that, re-runs, and only then learns the process can never work as this user. - if err := verifyRunnerUID(os.Getuid()); err != nil { + // Ahead of every operator-input check: this validates an engine fact the + // whole launch path depends on — that podman is new enough for the + // container userns remap (--userns=keep-id:uid=,gid=, podman ≥ 4.3; + // docs/designs/platform/compass-runner-arbitrary-uid/design.md). It takes no + // operator configuration and its failure is unconditional. Behind the flag + // checks, an operator on too-old a podman is told to set a token, fixes + // that, re-runs, and only then learns the engine can never launch a + // container — so the legible startup refusal must come first. + if err := runtime.NewPodmanCLI().VerifyUsernsRemapSupport(context.Background()); err != nil { return err } @@ -162,31 +166,6 @@ func run() error { // container, matching the runtime package's agent-user convention. const defaultAgentUID uint32 = 1000 -// verifyRunnerUID enforces the baked-uid invariant the agent image and the -// container runtime jointly depend on. The image bakes the agent user, /nix and -// $HOME as uid defaultAgentUID, and the containers are launched with podman's -// plain --userns=keep-id, which maps the host uid through unchanged rather than -// remapping it. A Runner running as any other uid therefore produces a container -// whose agent is that uid and so does not own /nix or its own home — the -// agent-managed devenv then fails deep inside the first nix build. Fail here -// instead, where the cause is visible. -// -// The caller passes the REAL uid (os.Getuid), deliberately, not the effective -// one: keep-id maps the invoking process's real uid into the container, so that -// is the uid the agent ends up as. A setuid-style effective-uid difference must -// therefore not satisfy this guard. -func verifyRunnerUID(uid int) error { - if uid == int(defaultAgentUID) { - return nil - } - return fmt.Errorf( - "the runner must run as uid %d, but it is running as uid %d: the agent "+ - "image bakes the agent user, /nix and $HOME as uid %d, and podman's "+ - "--userns=keep-id maps the host uid into the container unchanged, so "+ - "an agent launched by this runner would not own /nix", - defaultAgentUID, uid, defaultAgentUID) -} - // orEnv returns flagVal when non-empty, else the named environment variable. func orEnv(flagVal, envKey string) string { if flagVal != "" { diff --git a/go/cmd/compass-runner/main_test.go b/go/cmd/compass-runner/main_test.go index 35eae3f8..e4488e7a 100644 --- a/go/cmd/compass-runner/main_test.go +++ b/go/cmd/compass-runner/main_test.go @@ -9,28 +9,6 @@ import ( "github.com/sealedsecurity/compass/go/internal/runtime" ) -// The Runner's uid is a load-bearing precondition, not a preference: the agent -// image bakes /nix and $HOME as defaultAgentUID and podman's --userns=keep-id -// maps the host uid through unchanged, so a Runner on any other uid launches an -// agent that cannot write /nix. The guard must reject that at startup, and its -// message must name the invariant — an operator who only sees "permission -// denied" from a nix build three layers down cannot act on it. -func TestVerifyRunnerUID(t *testing.T) { - if err := verifyRunnerUID(int(defaultAgentUID)); err != nil { - t.Fatalf("verifyRunnerUID(%d) = %v, want nil", defaultAgentUID, err) - } - - err := verifyRunnerUID(int(defaultAgentUID) + 1) - if err == nil { - t.Fatalf("verifyRunnerUID(%d) = nil, want an error", defaultAgentUID+1) - } - for _, want := range []string{"1000", "1001", "/nix", "keep-id"} { - if !strings.Contains(err.Error(), want) { - t.Errorf("verifyRunnerUID error %q does not name %q", err, want) - } - } -} - // parseMount is the operator surface for --mount: a malformed value must be // rejected at flag-parse with a message an operator can act on (it names the bad // input and the host:container[:ro] shape), and a well-formed value must reach diff --git a/go/internal/runtime/agent.go b/go/internal/runtime/agent.go index 6e95b48c..34f730b8 100644 --- a/go/internal/runtime/agent.go +++ b/go/internal/runtime/agent.go @@ -248,6 +248,7 @@ func (r *AgentRuntime) createAndStart(ctx context.Context, spec AgentSpec) (Cont Name: spec.Name, CapAdd: []string{capNetAdmin}, Mounts: spec.Mounts, + UID: spec.Workspace.UID, // Keep the container alive so the Runner can exec into it; the agent is // driven via exec, not as the container's main process. Command: []string{"sleep", "infinity"}, @@ -279,8 +280,9 @@ func (r *AgentRuntime) provision(ctx context.Context, id ContainerID, spec Agent return r.ensureCheckoutDir(ctx, id, spec.Workspace) } -// armEgress arms the egress firewall as root (needs NET_ADMIN). After this, the -// agent user — with no capabilities — cannot alter the ruleset. +// armEgress arms the egress firewall as the image's default user (uid 1000) +// with CAP_NET_ADMIN. After this, an agent exec — run as the agent uid with no +// capabilities — cannot alter the ruleset. func (r *AgentRuntime) armEgress(ctx context.Context, id ContainerID, egress EgressPolicy) error { out, err := r.runtime.Exec(ctx, id, NewExecSpec("sh", "-c", egress.NftScript())) if err != nil { diff --git a/go/internal/runtime/egress_integrity_podman_test.go b/go/internal/runtime/egress_integrity_podman_test.go new file mode 100644 index 00000000..1ed4ef02 --- /dev/null +++ b/go/internal/runtime/egress_integrity_podman_test.go @@ -0,0 +1,111 @@ +//go:build podman + +package runtime + +// Egress-integrity boundary proof against real rootless podman: an agent exec — +// run as the agent uid with an explicit --user — holds an EMPTY effective +// capability set inside a NET_ADMIN container, so it cannot alter the egress +// ruleset the container's privileged entrypoint armed (egress.go:6-10, "the +// agent then runs as a non-root user whose capability set is empty, so it cannot +// flush or edit the ruleset even though the container nominally holds the +// capability"). +// +// What this pins: podman's own --user mechanism strips the container's ambient +// CAP_NET_ADMIN from an exec pinned to the agent uid — the property the hermetic +// spec tests (agentenv_test.go) cannot prove because they never spawn podman. It +// does NOT prove any production call site sets --user: those are pinned +// separately by agentenv_test.go TestExecSpecRunsAsWorkspaceUIDNotContainerRoot +// (the streaming agent session) and lifecycle_test.go (ExecAsAgent's nft flush +// is denied). Together the three lock both halves of the boundary: the call +// sites set --user, and --user actually drops the capability. +// +// Skipped (not failed) when podman isn't usable, matching lifecycle_test / +// config_mount_test. Build-tagged (podman) so it is not part of the hermetic gate. + +import ( + "context" + "os" + "os/exec" + "strconv" + "strings" + "testing" +) + +// capEffOf runs `grep CapEff /proc/self/status` inside container id as the given +// ExecSpec identity and returns the hex effective-capability mask (the token +// after "CapEff:"). It drives the real PodmanCLI.Exec so the --user plumbing +// under test is the one exercised in production. +func capEffOf(t *testing.T, ctx context.Context, cli *PodmanCLI, id ContainerID, spec ExecSpec) string { + t.Helper() + out, err := cli.Exec(ctx, id, spec) + if err != nil { + t.Fatalf("exec CapEff probe: %v", err) + } + if !out.Success() { + t.Fatalf("CapEff probe exited %d: %s", out.ExitCode, out.Stderr) + } + // /proc/self/status line: "CapEff:\t0000000000000000" + for _, line := range strings.Split(out.Stdout, "\n") { + if rest, ok := strings.CutPrefix(strings.TrimSpace(line), "CapEff:"); ok { + return strings.TrimSpace(rest) + } + } + t.Fatalf("no CapEff line in /proc/self/status: %q", out.Stdout) + return "" +} + +// TestAgentExecDropsNetAdminInNetAdminContainer is the egress-integrity +// regression: in a container granted CAP_NET_ADMIN (as every agent container +// is, agent.go createAndStart CapAdd), an exec pinned to the agent uid via +// --user must have an all-zero effective capability set, while the container's +// default-user exec (the armEgress identity) retains the capability. A regression +// that drops the --user on an agent exec makes the two masks equal and fails +// here. +func TestAgentExecDropsNetAdminInNetAdminContainer(t *testing.T) { + if !podmanUsable() { + t.Skip("rootless podman not usable in this environment") + } + ctx := context.Background() + cli := NewPodmanCLI() + + // A NET_ADMIN container remapped to the baked agent uid, mirroring a real + // agent container's create (agent.go createAndStart). + const agentUID uint32 = 1000 + spec := ContainerSpec{ + Image: "docker.io/library/alpine:latest", + Name: "compass-egress-integrity-" + strconv.Itoa(os.Getpid()), + UID: agentUID, + CapAdd: []string{capNetAdmin}, + Command: []string{"sleep", "infinity"}, + } + // Bring the NET_ADMIN container up, mirroring createStartExec's + // create/start/force-rm-teardown (userns_remap_test.go) but holding the id so + // we can exec against it twice with different identities. + _ = exec.Command("podman", "rm", "--force", spec.Name).Run() + t.Cleanup(func() { _ = exec.Command("podman", "rm", "--force", spec.Name).Run() }) + cid, err := cli.Create(ctx, spec) + if err != nil { + t.Fatalf("create container: %v", err) + } + if err := cli.Start(ctx, cid); err != nil { + t.Fatalf("start container: %v", err) + } + + probe := []string{"grep", "CapEff", "/proc/self/status"} + + // The armEgress identity: nil --user, runs as the image default user and + // inherits the container's CAP_NET_ADMIN so it can arm nft. + privileged := capEffOf(t, ctx, cli, cid, NewExecSpec(probe...)) + // The agent-work identity: explicit --user , empty capability set. + agent := capEffOf(t, ctx, cli, cid, NewExecSpec(probe...).AsUser(strconv.FormatUint(uint64(agentUID), 10))) + + if agent != "0000000000000000" { + t.Fatalf("agent-uid exec CapEff = %q, want an empty set %q: an --user agent exec must hold no capabilities so it cannot alter the egress ruleset (egress.go)", agent, "0000000000000000") + } + if privileged == agent { + t.Fatalf("default-user exec CapEff = %q equals the agent-uid exec's = %q: the NET_ADMIN the entrypoint arms with must not be inherited by agent execs", privileged, agent) + } + if !strings.Contains(privileged, "1000") { + t.Fatalf("default-user exec CapEff = %q, want the CAP_NET_ADMIN bit (0x1000) set — the armEgress identity must retain the capability it arms nft with", privileged) + } +} diff --git a/go/internal/runtime/podman.go b/go/internal/runtime/podman.go index 7af0f34a..4d4545ca 100644 --- a/go/internal/runtime/podman.go +++ b/go/internal/runtime/podman.go @@ -22,8 +22,9 @@ // This file is the container-runtime seam: a ContainerRuntime interface plus // PodmanCLI, its rootless-podman-CLI implementation. Rootless is a hard // requirement (compass.md §5.3, §7.1): no daemon, no root, no rootful fallback. -// Containers run with --userns=keep-id so files the agent writes in a -// bind-mount map back to the invoking user on the host. +// Containers run with --userns=keep-id:uid=,gid= so the +// invoking host user is mapped to the baked agent uid; files the agent writes +// in a bind-mount still map back to the invoking user on the host. // // Kill-on-abandon: the Rust original relied on tokio's kill_on_drop to reap a // subprocess whose future was dropped. Go has no Drop, so cancellation is @@ -79,21 +80,28 @@ type ContainerSpec struct { // agent itself runs as a non-root user with an empty capability set (see // egress.go). CapAdd []string - // Mounts is the read-only host bind mounts (e.g. a host cache mounted - // read-only). + // Mounts is the host bind mounts. Not all read-only: the config/cache + // mounts are read-only, but the per-container agent gateway socket is + // mounted read-write (the agent must connect() to it). Mounts []Mount // Env is the environment variables set on the container. Env map[string]string // Command is the long-lived entrypoint. The container stays up (the Runner // execs into it); a sleep loop when empty. Command []string + // UID is the container uid the invoking host user is mapped to via + // --userns=keep-id:uid=,gid= — the baked agent uid the image bakes /nix and + // $HOME as (the T1/T2 baked-agent-uid invariant; see + // docs/designs/platform/compass-runner-arbitrary-uid/design.md). + UID uint32 } // ExecSpec is how to run a command inside a container. type ExecSpec struct { Command []string - // User is the --user value. Nil runs as the image's default user (root); - // agent work always sets a uid so it runs unprivileged. + // User is the --user value. Nil runs as the image's default user (for the + // compass-agent image that is uid 1000, not root); agent work always sets a + // uid explicitly so it runs unprivileged. User *string // Workdir is the --workdir inside the container. Workdir *string @@ -144,8 +152,9 @@ func (o ExecOutput) Success() bool { return o.ExitCode == 0 } // ignored. type StreamingExecSpec struct { Command []string - // User is the --user value. Nil runs as the image's default user (root); - // agent work always sets a uid so it runs unprivileged. + // User is the --user value. Nil runs as the image's default user (for the + // compass-agent image that is uid 1000, not root); agent work always sets a + // uid so it runs unprivileged. User *string // Workdir is the --workdir inside the container. Workdir *string @@ -324,6 +333,7 @@ const defaultCommandTimeout = 120 * time.Second const ( argExec = "exec" argInteractive = "--interactive" + argFormat = "--format" ) // PodmanCLI is a ContainerRuntime over the podman CLI. @@ -353,6 +363,17 @@ func (p *PodmanCLI) WithTimeout(timeout time.Duration) *PodmanCLI { // Create assembles and runs `podman create`, returning the new container id. func (p *PodmanCLI) Create(ctx context.Context, spec ContainerSpec) (ContainerID, error) { + stdout, err := p.run(ctx, "podman create", createArgs(spec)) + if err != nil { + return "", err + } + return ContainerID(strings.TrimSpace(string(stdout))), nil +} + +// createArgs assembles the argv for `podman create`. Split out so the argv +// assembly is unit-testable without spawning podman, mirroring +// execStreamingArgs. +func createArgs(spec ContainerSpec) []string { // Preallocate: 4 fixed tokens (create, --name+value, --userns) + 2 per // cap/mount/env pair + image + command tokens, so the appends below don't // reallocate. @@ -360,9 +381,12 @@ func (p *PodmanCLI) Create(ctx context.Context, spec ContainerSpec) (ContainerID args = append(args, "create", "--name", spec.Name, - // Rootless uid mapping: files the agent writes in a bind-mount map back - // to the invoking user, not to a high subuid (compass.md §5.3). - "--userns=keep-id", + // Rootless uid remap: maps the invoking host user to the baked agent + // uid, so files the agent writes in a bind-mount still map back to the + // invoking user on the host (compass.md §5.3; + // docs/designs/platform/compass-runner-arbitrary-uid/design.md). gid + // collapses to uid: the image bakes gid==uid==1000. + fmt.Sprintf("--userns=keep-id:uid=%d,gid=%d", spec.UID, spec.UID), ) for _, cap := range spec.CapAdd { args = append(args, "--cap-add", cap) @@ -375,12 +399,63 @@ func (p *PodmanCLI) Create(ctx context.Context, spec ContainerSpec) (ContainerID } args = append(args, spec.Image) args = append(args, spec.Command...) + return args +} + +// minUsernsRemapMajor / minUsernsRemapMinor are the podman version floor for +// the --userns=keep-id:uid=,gid= remap Create relies on: keep-id:uid= is a +// podman 4.3+ option +// (docs/designs/platform/compass-runner-arbitrary-uid/design.md §(b)). There is +// no --uidmap fallback below it — the floor is hard. +const ( + minUsernsRemapMajor = 4 + minUsernsRemapMinor = 3 +) - stdout, err := p.run(ctx, "podman create", args) +// VerifyUsernsRemapSupport checks the engine is new enough for the userns remap +// Create depends on: podman ≥ 4.3, where --userns=keep-id:uid=,gid= is +// available. It probes `podman version --format {{.Client.Version}}` (for local +// rootless podman the client version is the engine version; remote client/server +// skew is out of scope) and errors below the floor, naming both the required +// floor and the found version so an operator on too-old a podman learns the +// cause at startup rather than deep inside the first container create. +func (p *PodmanCLI) VerifyUsernsRemapSupport(ctx context.Context) error { + stdout, err := p.run(ctx, "podman version", []string{"version", argFormat, "{{.Client.Version}}"}) if err != nil { - return "", err + return err } - return ContainerID(strings.TrimSpace(string(stdout))), nil + raw := strings.TrimSpace(string(stdout)) + major, minor, err := parsePodmanVersion(raw) + if err != nil { + return err + } + if major < minUsernsRemapMajor || (major == minUsernsRemapMajor && minor < minUsernsRemapMinor) { + return fmt.Errorf( + "podman %d.%d or newer is required (the container userns remap "+ + "--userns=keep-id:uid=,gid= is a %d.%d+ option), but this host has podman %s", + minUsernsRemapMajor, minUsernsRemapMinor, minUsernsRemapMajor, minUsernsRemapMinor, raw) + } + return nil +} + +// parsePodmanVersion parses the leading major.minor of a podman version string +// (e.g. "5.8.4" or "4.3.1-dev") into its numeric components. Split out so the +// floor comparison is unit-testable without spawning podman. An input without a +// parseable major.minor is an error. +func parsePodmanVersion(s string) (major, minor int, err error) { + fields := strings.SplitN(strings.TrimSpace(s), ".", 3) + if len(fields) < 2 { + return 0, 0, fmt.Errorf("unparseable podman version %q: want major.minor[.patch]", s) + } + major, err = strconv.Atoi(fields[0]) + if err != nil { + return 0, 0, fmt.Errorf("unparseable podman major version in %q: %w", s, err) + } + minor, err = strconv.Atoi(fields[1]) + if err != nil { + return 0, 0, fmt.Errorf("unparseable podman minor version in %q: %w", s, err) + } + return major, minor, nil } // Start starts a created container. @@ -652,7 +727,7 @@ func execStreamingArgs(id ContainerID, spec StreamingExecSpec) []string { // mount label. Split out so the argv assembly is unit-testable without spawning // podman, mirroring execStreamingArgs. func inspectMountLabelArgs(id ContainerID) []string { - return []string{"inspect", "--format", "{{.MountLabel}}", id.String()} + return []string{"inspect", argFormat, "{{.MountLabel}}", id.String()} } // mountArg assembles a `-v host:container[:ro],Z` argument. SELinux relabelling diff --git a/go/internal/runtime/podman_test.go b/go/internal/runtime/podman_test.go index 91ea866e..ea3775d8 100644 --- a/go/internal/runtime/podman_test.go +++ b/go/internal/runtime/podman_test.go @@ -91,6 +91,56 @@ func TestExecStreamingArgsAssemblesInteractiveExec(t *testing.T) { } } +// createArgs must emit the userns remap token that maps the invoking host user +// to the spec'd container uid (the baked agent uid). A regression to the bare +// --userns=keep-id token silently reintroduces the arbitrary-host-uid defect +// (the agent ends up as the host uid, not 1000, and cannot own /nix). +func TestCreateArgsRemapsUserns(t *testing.T) { + args := createArgs(ContainerSpec{Name: "c", Image: "img", UID: 1000}) + if !slices.Contains(args, "--userns=keep-id:uid=1000,gid=1000") { + t.Fatalf("createArgs = %q, want it to contain %q", args, "--userns=keep-id:uid=1000,gid=1000") + } +} + +// parsePodmanVersion + the floor comparison together decide the startup gate: +// the parse must yield the right major/minor (or an error on an unparseable +// string), and the floor comparison (the same predicate VerifyUsernsRemapSupport +// applies) must refuse below 4.3 and admit the floor and above. A wrong verdict +// either refuses a capable engine or lets a too-old one through to fail deep in +// the first create. +func TestParsePodmanVersion(t *testing.T) { + tests := []struct { + name string + in string + wantParseErr bool + wantRefused bool + }{ + {"below floor 3.4 (Ubuntu 22.04 LTS) is refused", "3.4.4", false, true}, + {"below floor 4.2 is refused", "4.2.0", false, true}, + {"at floor 4.3 is admitted", "4.3.1", false, false}, + {"dev box 5.8.4 is admitted", "5.8.4", false, false}, + {"garbage is a parse error", "not-a-version", true, false}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + major, minor, err := parsePodmanVersion(tc.in) + if tc.wantParseErr { + if err == nil { + t.Fatalf("parsePodmanVersion(%q) = (%d, %d, nil), want a parse error", tc.in, major, minor) + } + return + } + if err != nil { + t.Fatalf("parsePodmanVersion(%q) = unexpected error %v", tc.in, err) + } + refused := major < minUsernsRemapMajor || (major == minUsernsRemapMajor && minor < minUsernsRemapMinor) + if refused != tc.wantRefused { + t.Fatalf("floor verdict for %q (parsed %d.%d) = refused:%v, want refused:%v", tc.in, major, minor, refused, tc.wantRefused) + } + }) + } +} + func TestExecStreamingArgsMinimalOmitsUserAndWorkdir(t *testing.T) { spec := NewStreamingExecSpec("compass-agent") diff --git a/go/internal/runtime/userns_remap_test.go b/go/internal/runtime/userns_remap_test.go new file mode 100644 index 00000000..da1e43a4 --- /dev/null +++ b/go/internal/runtime/userns_remap_test.go @@ -0,0 +1,171 @@ +//go:build podman + +package runtime + +// Arbitrary-host-uid remap proof against real rootless podman +// (docs/designs/platform/compass-runner-arbitrary-uid/design.md §P3). The GA +// contract: a launch whose remap target differs from the invoking host uid +// still yields an agent that is the baked agent uid in-container and owns +// /nix-equivalent paths. +// +// The dev box runs as uid 1000, so the mapping test inverts the probe — remap +// to a NON-host uid and assert the mapping — which exercises the identical +// keep-id:uid= mechanism an arbitrary-host-uid deployment relies on. Against +// bare --userns=keep-id the in-container uid is the host uid (the defect); the +// remap flip makes it the spec'd uid. +// +// Cases 1-2 are alpine-based (no compass-agent image dependency); case 3 runs +// against the real compass-agent:latest when present, skipped otherwise. +// Skipped (not failed) when podman isn't usable, matching lifecycle_test / +// config_mount_test. Build-tagged (podman) so it is not part of the hermetic +// gate. + +import ( + "context" + "os" + "os/exec" + "strconv" + "strings" + "syscall" + "testing" +) + +// agentRemapImage is the real agent image case 3 probes for /nix ownership. +// Mirrors config_delivery_e2e_test.go's agentImage: a missing image means skip, +// not fail. +const agentRemapImage = "compass-agent:latest" + +// agentRemapImageExists reports whether the real agent image is present in +// local storage, mirroring config_delivery_e2e_test.go's agentImageExists. +func agentRemapImageExists() bool { + return exec.Command("podman", "image", "exists", agentRemapImage).Run() == nil +} + +// createStartExec creates + starts a container from spec, execs command inside +// it (as the container's default user), and returns the trimmed stdout. It +// registers teardown so a leaked container never collides with the next run. +func createStartExec(t *testing.T, ctx context.Context, cli *PodmanCLI, spec ContainerSpec, command ...string) ExecOutput { + t.Helper() + + // Force-remove any leftover from a crashed run so the name is free, then + // guard teardown (Go has no Drop). Deferred-cleanup discards in test code: + // the rm is best-effort — a failure means nothing to clean, not a test + // failure. + _ = exec.Command("podman", "rm", "--force", spec.Name).Run() + t.Cleanup(func() { _ = exec.Command("podman", "rm", "--force", spec.Name).Run() }) + + id, err := cli.Create(ctx, spec) + if err != nil { + t.Fatalf("create container: %v", err) + } + if err := cli.Start(ctx, id); err != nil { + t.Fatalf("start container: %v", err) + } + out, err := cli.Exec(ctx, id, NewExecSpec(command...)) + if err != nil { + t.Fatalf("exec %q: %v", command, err) + } + if !out.Success() { + t.Fatalf("exec %q exited %d: %s", command, out.ExitCode, out.Stderr) + } + return out +} + +// TestKeepIDRemapMapsHostUIDToSpecUID is the red/green heart of the slice: with +// a remap target distinct from the invoking host uid, `id -u` inside the +// container must equal the spec'd UID, not the host uid. RED against bare +// --userns=keep-id (in-container uid == host uid); GREEN once createArgs emits +// keep-id:uid=. +func TestKeepIDRemapMapsHostUIDToSpecUID(t *testing.T) { + if !podmanUsable() { + t.Skip("rootless podman not usable in this environment") + } + ctx := context.Background() + + hostUID := os.Getuid() + // A target distinct from the host uid so the mapping is observable. 2000 is + // arbitrary and differs from the dev-box host uid (1000). + const targetUID uint32 = 2000 + if int(targetUID) == hostUID { + t.Fatalf("target uid %d must differ from the host uid %d for the mapping to be observable", targetUID, hostUID) + } + + spec := ContainerSpec{ + Image: "docker.io/library/alpine:latest", + Name: "compass-usernsremap-map-" + strconv.Itoa(os.Getpid()), + UID: targetUID, + Command: []string{"sleep", "infinity"}, + } + out := createStartExec(t, ctx, NewPodmanCLI(), spec, "id", "-u") + + got := strings.TrimSpace(out.Stdout) + want := strconv.FormatUint(uint64(targetUID), 10) + if got != want { + t.Fatalf("in-container uid = %q, want %q (the remap must map host uid %d to the spec'd uid %d, not pass the host uid through)", + got, want, hostUID, targetUID) + } +} + +// TestKeepIDRemapBindMountRoundTrip proves §(c): a file the container user +// writes into a bind-mounted host dir lands on the host owned by the INVOKING +// host uid, not the mapped container uid. keep-id:uid=N maps the invoking host +// user to container uid N, so container-N writes still round-trip back to the +// invoker on the host. +func TestKeepIDRemapBindMountRoundTrip(t *testing.T) { + if !podmanUsable() { + t.Skip("rootless podman not usable in this environment") + } + ctx := context.Background() + + dir := t.TempDir() + spec := ContainerSpec{ + Image: "docker.io/library/alpine:latest", + Name: "compass-usernsremap-mount-" + strconv.Itoa(os.Getpid()), + UID: 2000, + Mounts: []Mount{{HostPath: dir, ContainerPath: "/mnt", ReadOnly: false}}, + Command: []string{"sleep", "infinity"}, + } + // Write the probe file as the container user (default user under the remap), + // then read back the host-side owner. + createStartExec(t, ctx, NewPodmanCLI(), spec, "touch", "/mnt/probe") + + info, err := os.Stat(dir + "/probe") + if err != nil { + t.Fatalf("stat host-side probe file: %v", err) + } + sys, ok := info.Sys().(*syscall.Stat_t) + if !ok { + t.Fatalf("stat_t unavailable on this platform: %T", info.Sys()) + } + if int(sys.Uid) != os.Getuid() { + t.Fatalf("host probe file owned by uid %d, want the invoking host uid %d (the bind-mount round-trip must land back on the invoker)", + sys.Uid, os.Getuid()) + } +} + +// TestKeepIDRemapAgentOwnsNix is the GA contract against the real image: launch +// the compass-agent image under the remap and assert /nix inside is owned by +// the baked agent uid (1000). Skipped when the image is absent (it is the heavy +// dogfood nix build, not an in-test one), mirroring config_delivery_e2e_test. +func TestKeepIDRemapAgentOwnsNix(t *testing.T) { + if !podmanUsable() { + t.Skip("rootless podman not usable in this environment") + } + if !agentRemapImageExists() { + t.Skip(agentRemapImage + " not present in local storage") + } + ctx := context.Background() + + spec := ContainerSpec{ + Image: agentRemapImage, + Name: "compass-usernsremap-nix-" + strconv.Itoa(os.Getpid()), + UID: 1000, + Command: []string{"sleep", "infinity"}, + } + out := createStartExec(t, ctx, NewPodmanCLI(), spec, "stat", "-c", "%u", "/nix") + + got := strings.TrimSpace(out.Stdout) + if got != "1000" { + t.Fatalf("/nix owner uid inside container = %q, want %q (the baked agent uid must own /nix under the remap)", got, "1000") + } +}