From 1d43511526c3b61ed1c086e8411c98f4ab452b69 Mon Sep 17 00:00:00 2001 From: Omry Yadan Date: Sat, 31 Oct 2026 03:44:13 +0800 Subject: [PATCH] E3.2: Refuse unqualified occupied exec Distinguish fresh-instance, occupied-container and host-native entry using a closed private mode and the existing locked owner state. Refuse unqualified mapped occupied and host-native requests before runtime effects, while preserving ordinary execution and the broad public mapped guards. Fresh classification grants no admission or fallback. Retain an owned same-UID peer and private file, directory, socket, pidfd and high-descriptor test surfaces for future transition proposals. The harness verifies current refusal and cleanup; it does not qualify live occupied exec or proc/ptrace isolation. Final entry planning and all-controlled-entry wiring remain later E slices. Validation: clean fresh independent full review; focused refusal/public-guard tests; Python packaging and CI-tool tests; targeted Go vet; complete shipping/conformance Go package tests; linux-amd64 build and CLI smoke. Retained WSL ordinary-tmpfs exclusion and explicit Docker skip apply. Exact-head remote CI and PR review remain required. --- .../dockerdeploy/identity_mapping_guard.go | 4 +- internal/dockerdeploy/mapped_entry.go | 55 ++++++ .../dockerdeploy/mapped_entry_linux_test.go | 167 ++++++++++++++++++ internal/dockerdeploy/mapped_entry_test.go | 92 ++++++++++ 4 files changed, 317 insertions(+), 1 deletion(-) create mode 100644 internal/dockerdeploy/mapped_entry.go create mode 100644 internal/dockerdeploy/mapped_entry_linux_test.go create mode 100644 internal/dockerdeploy/mapped_entry_test.go diff --git a/internal/dockerdeploy/identity_mapping_guard.go b/internal/dockerdeploy/identity_mapping_guard.go index 1cae3960..ba3051df 100644 --- a/internal/dockerdeploy/identity_mapping_guard.go +++ b/internal/dockerdeploy/identity_mapping_guard.go @@ -10,7 +10,9 @@ import ( // A portable declaration is intent, including when its leaves are unresolved. // Later reviewed runtime admission must replace this guard as one complete cut. func unmappedRuntimeDocumentV1(state deploy.StateV1) (blueprint.Document, error) { - document, err := blueprint.DecodeResolvedDocumentV1(state.Blueprint) + // The public guard continues to inhibit even fresh mapped entry. The private + // mode boundary adds no public activation or occupied-exec qualification. + document, err := runtimeDocumentForEntryModeV1(state, mappedEntryFreshInstanceV1) if err != nil { return blueprint.Document{}, err } diff --git a/internal/dockerdeploy/mapped_entry.go b/internal/dockerdeploy/mapped_entry.go new file mode 100644 index 00000000..5bc8e724 --- /dev/null +++ b/internal/dockerdeploy/mapped_entry.go @@ -0,0 +1,55 @@ +package dockerdeploy + +import ( + "errors" + "fmt" + + "github.com/omry/reploy/internal/blueprint" + "github.com/omry/reploy/internal/deploy" +) + +// Entry mode describes the operation's required execution context. It is +// selected by trusted entry code, never by a public qualification flag. Do not +// replace occupied entry with a fresh instance when its mode is unsupported. +type mappedEntryModeV1 uint8 + +const ( + mappedEntryFreshInstanceV1 mappedEntryModeV1 = iota + 1 + mappedEntryOccupiedExecV1 + mappedEntryHostNativeV1 +) + +var ( + errMappedOccupiedEntryV1 = errors.New("mapped occupied-container entry is unsupported: no qualified exec transition") + errMappedHostNativeEntryV1 = errors.New("mapped host-native entry is unsupported") +) + +// runtimeDocumentForEntryModeV1 consumes the existing owner's locked state. +// Declaration alone counts as intent, including unresolved mapping leaves; +// retained mapping authority still counts if the declaration was removed. +// +// This is a pure refusal boundary, not admission. Fresh instance classification +// grants no permission to create, release or attach. Public inhibition and +// fresh mapped admission remain separate mandatory checks. In particular, +// caller capability lists, UID strings and expected-state IDs are not inputs. +func runtimeDocumentForEntryModeV1(state deploy.StateV1, mode mappedEntryModeV1) (blueprint.Document, error) { + switch mode { + case mappedEntryFreshInstanceV1, mappedEntryOccupiedExecV1, mappedEntryHostNativeV1: + default: + return blueprint.Document{}, fmt.Errorf("unsupported runtime entry mode %d", mode) + } + document, err := blueprint.DecodeResolvedDocumentV1(state.Blueprint) + if err != nil { + return blueprint.Document{}, err + } + mapped := state.IdentityMapping != nil || document.Environment.Runtime.IdentityMapping != nil || document.Environment.Runtime.IdentityPolicy != nil + if mapped { + switch mode { + case mappedEntryOccupiedExecV1: + return blueprint.Document{}, fmt.Errorf("environment %q: %w", document.Environment.ID, errMappedOccupiedEntryV1) + case mappedEntryHostNativeV1: + return blueprint.Document{}, fmt.Errorf("environment %q: %w", document.Environment.ID, errMappedHostNativeEntryV1) + } + } + return document, nil +} diff --git a/internal/dockerdeploy/mapped_entry_linux_test.go b/internal/dockerdeploy/mapped_entry_linux_test.go new file mode 100644 index 00000000..9f23a312 --- /dev/null +++ b/internal/dockerdeploy/mapped_entry_linux_test.go @@ -0,0 +1,167 @@ +//go:build linux + +package dockerdeploy + +import ( + "bufio" + "context" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "reflect" + "strings" + "testing" + "time" + + "golang.org/x/sys/unix" +) + +// This owned same-UID peer is deliberately outside a product container. Future +// occupied-entry proposals must test proc/ptrace and capability access against +// such an existing process, not only against their newly started helper. The +// current slice qualifies refusal only; it performs no authority transition. +func TestMappedOccupiedPeerV1(t *testing.T) { + if os.Getenv("REPLOY_TEST_OCCUPIED_PEER") != "child" { + return + } + fmt.Fprintln(os.Stdout, "ready") + _, _ = io.Copy(io.Discard, os.Stdin) + os.Exit(0) +} + +type mappedOccupiedHarnessV1 struct { + pid int + privatePath string + descriptors []*os.File + status []byte + procError error +} + +// Retain real private file/directory/socket/pidfd/high descriptors and a live +// same-UID peer. No fixture value is production evidence or a qualification +// flag. A future successful transition needs separately reviewed probes for +// proc/ptrace access and leaks; today's gate must reach none of those effects. +func newMappedOccupiedHarnessV1(t *testing.T) mappedOccupiedHarnessV1 { + t.Helper() + h := mappedOccupiedHarnessV1{privatePath: filepath.Join(t.TempDir(), "private")} + if err := os.WriteFile(h.privatePath, []byte("private-entry-canary"), 0600); err != nil { + t.Fatal(err) + } + retain := func(file *os.File) { + h.descriptors = append(h.descriptors, file) + t.Cleanup(func() { _ = file.Close() }) + } + for _, name := range []string{h.privatePath, filepath.Dir(h.privatePath)} { + file, err := os.Open(name) + if err != nil { + t.Fatal(err) + } + retain(file) + } + sockets, err := unix.Socketpair(unix.AF_UNIX, unix.SOCK_STREAM|unix.SOCK_CLOEXEC, 0) + if err != nil { + t.Fatal(err) + } + for _, fd := range sockets { + retain(os.NewFile(uintptr(fd), "private-socket")) + } + high, err := unix.FcntlInt(h.descriptors[0].Fd(), unix.F_DUPFD_CLOEXEC, 4096) + if err != nil { + t.Fatalf("high descriptor fixture unavailable (no qualification): %v", err) + } + retain(os.NewFile(uintptr(high), "private-high-file")) + // testing cancels t.Context before cleanup runs. Keep the owned peer alive + // until our cleanup closes its input, while retaining a hard reap deadline. + ctx, cancel := context.WithTimeout(context.WithoutCancel(t.Context()), 15*time.Second) + t.Cleanup(cancel) + self, err := os.Executable() + if err != nil { + t.Fatal(err) + } + peer := exec.CommandContext(ctx, self, "-test.run=^TestMappedOccupiedPeerV1$") + peer.Env = append(os.Environ(), "REPLOY_TEST_OCCUPIED_PEER=child") + input, err := peer.StdinPipe() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = input.Close() }) + output, err := peer.StdoutPipe() + if err != nil { + t.Fatal(err) + } + if err := peer.Start(); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + _ = input.Close() + if err := peer.Wait(); err != nil { + t.Errorf("reap owned peer: %v", err) + } + }) + line, err := bufio.NewReader(output).ReadString('\n') + if err != nil || line != "ready\n" { + t.Fatalf("owned peer readiness: %q %v", line, err) + } + h.pid = peer.Process.Pid + pidfd, err := unix.PidfdOpen(h.pid, 0) + if err != nil { + t.Fatalf("pidfd fixture unavailable (no qualification): %v", err) + } + retain(os.NewFile(uintptr(pidfd), "private-peer-pidfd")) + h.status, err = os.ReadFile(fmt.Sprintf("/proc/%d/status", h.pid)) + if err != nil { + t.Fatal(err) + } + for _, field := range []string{"Uid:", "Gid:", "CapPrm:", "CapEff:", "CapBnd:", "CapInh:", "CapAmb:", "TracerPid:"} { + if !strings.Contains(string(h.status), field) { + t.Fatalf("peer status lacks %s", field) + } + } + uidLine := fmt.Sprintf("Uid:\t%d\t%d\t%d\t%d", os.Getuid(), os.Geteuid(), os.Geteuid(), os.Geteuid()) + if !strings.Contains(string(h.status), uidLine) { + t.Fatal("fixture peer does not share current UID") + } + mem, err := os.Open(fmt.Sprintf("/proc/%d/mem", h.pid)) + h.procError = err + if err == nil { + retain(mem) + } + return h +} + +func TestMappedOccupiedRefusalWithLiveAdversarialSurfacesV1(t *testing.T) { + h := newMappedOccupiedHarnessV1(t) + t.Logf("owned same-UID peer=%d, proc-mem open=%v; no ptrace or exec qualification claimed", h.pid, h.procError) + for _, kind := range []string{"declaration", "unresolved", "policy", "retained"} { + state := mappedEntryStateFixtureV1(t, kind) + for _, mode := range []mappedEntryModeV1{mappedEntryOccupiedExecV1, mappedEntryHostNativeV1} { + _, err := runtimeDocumentForEntryModeV1(state, mode) + if !errors.Is(err, errMappedOccupiedEntryV1) && !errors.Is(err, errMappedHostNativeEntryV1) { + t.Fatal("adversarial occupied entry did not refuse", err) + } + } + // Exercise a real public entry, with all runtime callbacks instrumented. + // Its broad public guard must still stop before build, helpers, engine + // create/start/attach or a fresh-container fallback can be selected. + current, _ := runtimeCurrentBuildFixture(t) + current.State = state + dir := t.TempDir() + var order []string + err := runCurrentShellV1(t.Context(), CurrentShellRunInputV1{DeploymentDir: dir}, currentShellRunTestBackend(t, dir, current, CurrentRuntimePlanV1{}, &order)) + if err == nil || !reflect.DeepEqual(order, []string{"acquire", "store", "state"}) { + t.Fatalf("public mapped shell reached effects: %v %v", order, err) + } + } + data, err := os.ReadFile(h.privatePath) + if err != nil || string(data) != "private-entry-canary" { + t.Fatal("private fixture changed during refusal", err) + } + for _, file := range h.descriptors { + if _, err := file.Stat(); err != nil { + t.Fatalf("private descriptor damaged during refusal: %s: %v", file.Name(), err) + } + } +} diff --git a/internal/dockerdeploy/mapped_entry_test.go b/internal/dockerdeploy/mapped_entry_test.go new file mode 100644 index 00000000..8df9e2e0 --- /dev/null +++ b/internal/dockerdeploy/mapped_entry_test.go @@ -0,0 +1,92 @@ +package dockerdeploy + +import ( + "encoding/json" + "errors" + "reflect" + "strings" + "testing" + + "github.com/omry/reploy/internal/blueprint" + "github.com/omry/reploy/internal/deploy" +) + +func mappedEntryStateFixtureV1(t *testing.T, kind string) deploy.StateV1 { + t.Helper() + current, _ := runtimeCurrentBuildFixture(t) + state := identityGuardDocumentV1(t, current.State, false, false) + switch kind { + case "ordinary": + case "declaration": + state = identityGuardDocumentV1(t, state, true, false) + case "unresolved": + state = identityGuardDocumentV1(t, state, true, true) + case "policy": + state = policyGuardStateV1(t, state) + case "retained": + // A declaration removed from the blueprint cannot erase retained + // mapping authority, even if the remaining state is incomplete. + state.IdentityMapping = &deploy.IdentityMappingStateV1{} + default: + t.Fatalf("unknown entry fixture %q", kind) + } + return state +} + +func TestMappedEntryModesV1(t *testing.T) { + for _, kind := range []string{"ordinary", "declaration", "unresolved", "policy", "retained"} { + t.Run(kind, func(t *testing.T) { + state := mappedEntryStateFixtureV1(t, kind) + before, err := json.Marshal(state) + if err != nil { + t.Fatal(err) + } + wantDocument, err := blueprint.DecodeResolvedDocumentV1(state.Blueprint) + if err != nil { + t.Fatal(err) + } + for _, mode := range []mappedEntryModeV1{mappedEntryFreshInstanceV1, mappedEntryOccupiedExecV1, mappedEntryHostNativeV1} { + document, err := runtimeDocumentForEntryModeV1(state, mode) + if kind == "ordinary" || mode == mappedEntryFreshInstanceV1 { + if err != nil || !reflect.DeepEqual(document, wantDocument) { + t.Fatalf("mode %d changed ordinary document or fresh classification: %v", mode, err) + } + } else { + want := errMappedOccupiedEntryV1 + if mode == mappedEntryHostNativeV1 { + want = errMappedHostNativeEntryV1 + } + if !errors.Is(err, want) || !reflect.DeepEqual(document, blueprint.Document{}) { + t.Fatalf("mode %d returned document or wrong refusal: %v", mode, err) + } + } + } + after, err := json.Marshal(state) + if err != nil || string(before) != string(after) { + t.Fatal("entry classification changed owner state", err) + } + // Fresh classification cannot bypass the unchanged public guard. + _, err = unmappedRuntimeDocumentV1(state) + if (err == nil) != (kind == "ordinary") { + t.Fatalf("public guard changed for %s: %v", kind, err) + } + }) + } +} + +func TestMappedEntryInvalidModeAndBlueprintV1(t *testing.T) { + for _, kind := range []string{"ordinary", "retained", "policy"} { + state := mappedEntryStateFixtureV1(t, kind) + for _, mode := range []mappedEntryModeV1{0, 4, 255} { + if _, err := runtimeDocumentForEntryModeV1(state, mode); err == nil || !strings.Contains(err.Error(), "unsupported runtime entry mode") { + t.Fatalf("unknown mode %d accepted: %v", mode, err) + } + } + state.Blueprint = blueprint.ResolvedDocumentV1("") + for _, mode := range []mappedEntryModeV1{mappedEntryFreshInstanceV1, mappedEntryOccupiedExecV1, mappedEntryHostNativeV1} { + if document, err := runtimeDocumentForEntryModeV1(state, mode); err == nil || !reflect.DeepEqual(document, blueprint.Document{}) { + t.Fatalf("malformed owner blueprint returned a document in mode %d: %v", mode, err) + } + } + } +}