Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changes/unreleased/+protect-host-system-trees.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
kind: Security
body: Reject ordinary application-runtime binds to the host filesystem root, `/proc`, `/dev`, and `/sys`, including symlink and protected-filesystem aliases; reject unsupported remote Docker endpoints; and replace generic root-runtime warnings with precise diagnostics for prohibited authority.
39 changes: 33 additions & 6 deletions docs/BACKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,14 @@ This file is the day-to-day queue for design and implementation gaps.

## Now

- [ ] `P1` Define confinement for special files nested inside host directory
binds. A launch-time recursive scan for sockets and device nodes adds
unbounded source-tree latency and provides only a point-in-time result.
Choose a durable mechanism or explicitly narrow the security contract,
then add focused cross-platform tests without weakening ordinary
read-only project mounts.
- [ ] `P1` Implement the initial coarse application-network policy. Preserve
independent public and local policy intent with both denied by default,
apply it consistently to workloads, commands, shells, and lifecycle
commands, and use only proven isolation and endpoint primitives from the
active runtime backend. Permit exact declared inbound endpoints without
granting general local access. Fail closed when a backend cannot realize
a requested combination, and do not represent this slice as destination-,
domain-, or packet-level filtering.

## Pre-release

Expand Down Expand Up @@ -183,6 +185,17 @@ This file is the day-to-day queue for design and implementation gaps.

## Post-v1

- [ ] `P2` Design explicit remote Docker support.
Replace today's rejected ambient `DOCKER_HOST` and remote-context behavior
with an intentional distributed-runtime contract. Define input snapshot
upload, output-file and output-dir extraction with safe local publication,
image export or remote placement, port forwarding, remote identity and
permission semantics, authenticated transport, lifecycle ownership,
interruption recovery, deterministic cleanup, and a defensible way to
establish or replace host-path namespace equivalence. Keep local Docker
Engine and Docker Desktop behavior distinct from generic remote daemons,
containerized Reploy, and Unix-socket proxies.

- [ ] `P2` Consider a Reploy host configuration surface.
Keep a standing inventory of concrete operator- or host-owned settings
that do not belong in blueprints, staging overrides, or installation
Expand All @@ -192,6 +205,20 @@ This file is the day-to-day queue for design and implementation gaps.
fixed host-owned limits for controlled-session endpoint streams and
connection-open rates.

- [ ] `P2` Design and implement a Reploy userland L3 policy gateway. Keep this
separate from the initial public/local kill switches and controlled
sessions. Define a capability-free application network namespace, a
one-shot route initializer, an isolated data path whose only peer is the
gateway, private gateway control, root-resistant route invariants,
IPv4/IPv6 and DNS policy, destination and port grants, auditing, resource
limits, failure behavior, reconciliation, and Docker/Podman plus Desktop
integration. Treat native engine primitives as fast paths rather than
exposing backend network modes as product policy. Replace or mediate the
initial controlled-session host-loopback endpoint publication so only the
lease-owned Host Reploy operation can reach the recorded application;
include multi-user-host tests proving unrelated local processes cannot
bypass the session endpoint grant.

- [ ] `P2` Evaluate and prioritize the Dingo development-environment gaps.
Use `docs/DINGO_GAPS.md` as the needs and evidence record for portable
checkout binding, development execution, shell initialization, pinned
Expand Down
116 changes: 94 additions & 22 deletions docs/CONTROLLED_SESSION_DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,12 +76,13 @@ containers use the host account selected by
materializes the local account through Linux account databases; other target-OS
backends may realize the same contract differently.

If the effective runtime user is root, Reploy emits a precise warning that the
application can interfere with more of its container. Root does not implicitly
grant capabilities, host input or shared-state mounts, network access,
privileged mode, or daemon access. Root-safe `--output-file` and `--output-dir`
are separate global runtime contracts and remain rejected until their focused
pre-release review and implementation are complete.
Root remains an explicit runtime identity, but selecting it does not produce a
generic runtime warning. Reploy instead rejects prohibited combinations with
precise diagnostics. Root does not implicitly grant capabilities, host input
or shared-state mounts, network access, privileged mode, or daemon access.
Root-safe `--output-file` and `--output-dir` are separate global runtime
contracts and remain rejected until their focused pre-release review and
implementation are complete.

## Context

Expand Down Expand Up @@ -635,13 +636,13 @@ user-scope Reploy was invoked as root, or because a system-scope installation
explicitly selected root. It is never inherited merely from the base image's
configured `USER`.

A root runtime identity must emit a warning equivalent to:

> The application will run as root inside its container. Root can bypass
> application-level file permissions. Host input and shared-state mounts are
> prohibited. Explicit root output contracts require their separately reviewed
> safeguards. Network access and Linux capabilities remain restricted unless
> separately granted.
A root runtime identity does not emit a generic warning. With the global
sandbox enforced, its additional authority is limited to container-scoped
root-owned image content, declared persistent storage, and processes using the
same identity. Prohibited combinations fail with diagnostics that identify the
specific rejected authority. If a future capability grants root broader
authority, that capability's explicit opt-in surface must disclose the added
risk rather than making ordinary root execution noisy.

A root runtime identity does not imply:

Expand Down Expand Up @@ -670,6 +671,60 @@ approved project directory. A client cannot turn it into an arbitrary host-path
selector through the session protocol. Original project source is never exposed
through a writable bind.

An explicit host directory bind grants access to every unmasked entry below
that directory. Read-only mode prevents ordinary file mutation, but does not
neutralize Unix sockets, device nodes, FIFOs, or nested mount points. Reploy
does not recursively scan a live source tree: such a scan has unbounded launch
cost and provides only a race-prone point-in-time observation. A caller that
requires stronger isolation must use no host bind or a future filtered-copy
workspace. This is a deliberate narrowing of the direct-bind security
contract, not a claim that active host objects have been confined.

Direct host binds also trust the selected host pathname namespace to remain
stable until Docker establishes the mount. Reploy does not defend against a
separate host-side actor retargeting the source path during launch or using
Docker daemon access to alter the container. Such actors already hold authority
outside the controller/workload isolation boundary. The launched workload
cannot create this race itself because Docker establishes its mounts before
starting the workload process.

Generic remote Docker daemons are unsupported. Reploy requires a local Unix
socket or Windows named-pipe endpoint, including the local endpoint presented
by Docker Desktop. This prevents local validation and output contracts from
silently applying to paths, ports, images, identities, and lifecycle state on
another machine. A future remote-Docker design requires explicit input upload,
output extraction and local publication, image placement, port forwarding,
authentication, cleanup, and recovery semantics rather than inherited Docker
context behavior.

On native Linux, the operator must run Reploy directly in the host namespace
served by the local Docker Engine. Running Reploy inside a container with a
host socket mounted, or placing a local Unix-socket proxy in front of another
daemon, is unsupported because the Docker API cannot prove that Reploy and the
daemon resolve host paths in the same mount namespace. Unix-socket and
named-pipe classification rejects ordinary remote Docker configuration; it is
not a security attestation for an operator-controlled socket. Docker Desktop
is the intentional exception because its native client integration supplies the
supported host-path sharing and port-forwarding contract.

Ordinary host binds reject the host filesystem root and canonical sources at
or below `/proc`, `/dev`, or `/sys`, including symlink aliases. On Linux,
Reploy also rejects bind mounts that expose the same filesystem root and checks
filesystem identity so procfs, sysfs, cgroup hierarchies, device filesystems,
and kernel control or observation filesystems remain prohibited when exposed
through another path. Linux proc magic links are rejected before canonical
resolution, including when procfs is reached through a symlink alias, so they
cannot resolve differently for Reploy and Docker. Where Linux reports mount
identity, Reploy rejects aliases of every mount rooted below `/proc`, `/dev`,
or `/sys` while preserving unrelated mounts of the same filesystem type. On
Linux kernels without no-magic-link path resolution, direct paths remain
available but symlinked host sources fail closed. On macOS, native devfs and
procfs sources are likewise rejected.
Containers keep Docker's container-scoped `/proc` and restricted `/dev`; those
are not host binds. Hardware or host-observation access, if later justified by
a compelling use case, requires a separately designed explicit capability
rather than an ordinary mount.

Root inside any Reploy application container may not receive host input or
shared-state binds, including read-only binds. Read-only prevents modification
but does not make exposed content confidential from container root. Reploy
Expand Down Expand Up @@ -786,6 +841,13 @@ denial includes host gateways, Docker peers outside the granted operation,
loopback redirection, private and link-local address ranges, IPv6 local ranges,
and infrastructure metadata endpoints.

The initial implementation preserves this coarse public/local policy intent
and exact declared endpoint grants, using only backend isolation and endpoint
primitives whose behavior Reploy can verify. A backend that cannot realize a
requested combination fails closed. This slice does not introduce a custom
packet gateway and must not claim destination-, port-, domain-, DNS-, or
packet-level enforcement beyond what the selected primitive actually proves.

A controller may receive an explicit session-local grant to a declared
workload endpoint. That grant is not treated as general local-network
access.
Expand Down Expand Up @@ -1077,9 +1139,13 @@ remain separate prerequisite slices. Root host authority is now enforced at
runtime: host sources are classified as input, shared state, or explicit
output; UID 0 is rejected for all three before container creation; and root
output options are rejected before host-path preparation. Docker-managed
volumes and tmpfs remain available to root. Durable confinement of special
files nested inside a non-root directory bind remains unresolved and must not
be represented as solved by an expensive launch-time snapshot alone.
volumes and tmpfs remain available to root. Ordinary binds also reject
canonical host root, `/proc`,
`/dev`, and `/sys` sources plus equivalent protected filesystem mounts detected
through native filesystem identity. Explicit non-root directory binds
intentionally grant access to their remaining unmasked contents, including
nested active objects; this narrowed contract avoids representing a recursive
launch-time scan as durable confinement.

### Slice 2: Controlled-Session Lifecycle Core

Expand Down Expand Up @@ -1151,11 +1217,16 @@ lease protocol.

### Network Isolation and Audit

Define general public and local kill switches, direct-egress enforcement, proxy
behavior, DNS control, IPv6, metadata protection, and auditability as a
separate Reploy/agent-sandbox design. The one-way, exact endpoint forwarding
used by the initial controlled session is intentionally narrower than that
future surface.
After the coarse public/local kill switches, define a separate Reploy userland
L3 policy gateway for finer network control. Its design should cover a
capability-free application network namespace, one-shot route initialization,
an isolated data path whose only peer is the gateway, private gateway control,
root-resistant route invariants, direct-egress prevention, destination and
port grants, DNS and IPv6 policy, metadata protection, auditing, resource
limits, failure behavior, reconciliation, and portable Docker/Podman
integration. The one-way, exact endpoint forwarding used by the initial
controlled session remains intentionally narrower and does not depend on this
later gateway.

### Disposable Writable Workspaces

Expand Down Expand Up @@ -1192,7 +1263,8 @@ analysis. Privileged application containers remain outside this design.
- Installed system-scope application containers use the configured host
service account's numeric identity under the blueprint's container-local
account name.
- Root application containers are possible but visibly weaker.
- Root application containers are possible but receive no implicit additional
authority; prohibited combinations fail with precise diagnostics.
- Root application containers never receive host input or shared-state binds;
local source requires the separately designed disposable-copy capability.
Explicit root output-only binds remain unavailable until their separate
Expand Down
3 changes: 3 additions & 0 deletions internal/dockerdeploy/buildkit_probe.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,9 @@ func minimumDockerVersion(value string, minimumMajor int, minimumMinor int) bool
}

func executeDockerOutput(ctx context.Context, args ...string) (string, error) {
if err := requireDefaultLocalDockerEndpointV1(ctx); err != nil {
return "", err
}
ctx, end := buildprofile.Start(ctx, dockerProfileOperation(args))
command := exec.CommandContext(ctx, "docker", args...)
output, err := command.CombinedOutput()
Expand Down
22 changes: 15 additions & 7 deletions internal/dockerdeploy/compose.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,17 +41,22 @@ const defaultDockerPreflightTimeout = 5 * time.Second
var dockerPreflight = checkDockerResponsive

func runCommand(spec CommandSpec, options RunOptions) error {
if spec.Name == "docker" {
return runDockerCommand(spec, options)
}
return runCommandWithoutDockerPreflight(spec, options)
}

func runDockerCommand(spec CommandSpec, options RunOptions) error {
ctx := options.Context
if ctx == nil {
ctx = context.Background()
}
if spec.Name == "docker" {
_, end := buildprofile.Start(ctx, "Docker preflight")
err := dockerPreflight(ctx, spec, effectiveDockerPreflightTimeout(options.DockerPreflightTimeout))
end(err)
if err != nil {
return err
}
_, end := buildprofile.Start(ctx, "Docker preflight")
err := dockerPreflight(ctx, spec, effectiveDockerPreflightTimeout(options.DockerPreflightTimeout))
end(err)
if err != nil {
return err
}
return runCommandWithoutDockerPreflight(spec, options)
}
Expand Down Expand Up @@ -120,6 +125,9 @@ func effectiveDockerPreflightTimeout(timeout time.Duration) time.Duration {
}

func checkDockerResponsive(ctx context.Context, spec CommandSpec, timeout time.Duration) error {
if err := requireLocalDockerEndpointV1(ctx, spec, timeout); err != nil {
return err
Comment on lines +128 to +129

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Validate endpoints for bypassed Docker runners

When the installed system service starts, its actual compose up is passed to runCommandWithoutDockerPreflight from installed_service_container.go:101-108, so this endpoint check is never applied to the container-creation command. If the active context changes after the earlier image-reference probe, that command can target a remote daemon after host sources were validated locally, recreating the namespace mismatch this restriction is intended to prevent. Fresh evidence beyond the earlier cached-check finding is that removing the cache still leaves this independent command path bypassing endpoint validation entirely.

Useful? React with 👍 / 👎.

}
preflightCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()

Expand Down
29 changes: 28 additions & 1 deletion internal/dockerdeploy/compose_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,33 @@ func TestRunCommandWithoutDockerPreflightRunsKnownFollowup(t *testing.T) {
}
}

func TestRunDockerCommandPreflightsAbsoluteExecutable(t *testing.T) {
dir := t.TempDir()
dockerPath := writeFakeCommand(
t,
dir,
"configured-docker",
"#!/bin/sh\nexit 0\n",
"@echo off\r\nexit /b 0\r\n",
)
preflightCalled := false
restore := stubDockerPreflight(t, func(_ context.Context, spec CommandSpec, _ time.Duration) error {
preflightCalled = true
if spec.Name != dockerPath {
t.Fatalf("preflight executable = %q, want %q", spec.Name, dockerPath)
}
return nil
})
defer restore()

if err := runDockerCommand(CommandSpec{Name: dockerPath, Args: []string{"version"}}, RunOptions{}); err != nil {
t.Fatal(err)
}
if !preflightCalled {
t.Fatal("absolute Docker executable bypassed preflight")
}
}

func TestCheckDockerResponsiveUsesServerVersion(t *testing.T) {
dir := t.TempDir()
logPath := filepath.Join(dir, "argv.log")
Expand All @@ -149,7 +176,7 @@ func TestCheckDockerResponsiveUsesServerVersion(t *testing.T) {

err := checkDockerResponsive(
context.Background(),
CommandSpec{Name: dockerPath, Env: []string{"DOCKER_ARGV_LOG=" + logPath}},
CommandSpec{Name: dockerPath, Env: []string{"DOCKER_HOST=unix:///var/run/docker.sock", "DOCKER_CONTEXT=", "DOCKER_ARGV_LOG=" + logPath}},
defaultDockerPreflightTimeout,
)
if err != nil {
Expand Down
2 changes: 1 addition & 1 deletion internal/dockerdeploy/control_admission.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ func AwaitControlAdmissionWithNoticeV1(
return nil
}
},
removeContainer: runCommandWithoutDockerPreflight,
removeContainer: runCommand,
})
}

Expand Down
44 changes: 44 additions & 0 deletions internal/dockerdeploy/control_admission_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"path/filepath"
"strings"
"testing"
"time"

"github.com/omry/reploy/internal/deploy"
)
Expand Down Expand Up @@ -110,6 +111,49 @@ func TestAwaitControlAdmissionV1ReturnsHeldAfterEarlierRunAndKeepsLaterRunBehind
}
}

func TestAwaitControlAdmissionV1PreflightsRecoveredContainerCleanup(t *testing.T) {
dir := t.TempDir()
operation, err := deploy.AcquireOperationLock(t.Context(), dir)
if err != nil {
t.Fatal(err)
}
abandoned := liveRunAdmissionFixtureV1("run-0000000000000001", false)
if _, err := operation.AdmitLiveRunV1(abandoned, false); err != nil {
t.Fatal(err)
}
container := "demo-" + abandoned.ID
if err := operation.RecordLiveRunContainerV1(abandoned.ID, container); err != nil {
t.Fatal(err)
}
marker := controlAdmissionFixtureV1("control-0000000000000001", deploy.ControlOperationInstallV1)
lease, err := operation.AcquireControlLeaseV1(marker.ID)
if err != nil {
t.Fatal(err)
}
preflightCalls := 0
restore := stubDockerPreflight(t, func(context.Context, CommandSpec, time.Duration) error {
preflightCalls++
return errors.New("remote Docker endpoint rejected")
})
defer restore()

admitted, err := AwaitControlAdmissionV1(t.Context(), dir, operation, marker, false)
if err != nil {
t.Fatal(err)
}
if preflightCalls != 1 {
t.Fatalf("Docker preflight calls = %d, want 1", preflightCalls)
}
queue, _, err := admitted.ReadLiveRunQueueV1()
if err != nil || len(queue.Runs) != 1 || queue.Runs[0].ID != marker.ID ||
len(queue.Cleanup) != 1 || queue.Cleanup[0].Container != container {
t.Fatalf("queue after rejected remote cleanup = %#v, %v", queue, err)
}
if err := CompleteControlAdmissionV1(admitted, marker.ID, lease); err != nil {
t.Fatal(err)
}
}

func TestAwaitControlAdmissionV1ExplainsLifecycleWait(t *testing.T) {
dir := t.TempDir()
operation, err := deploy.AcquireOperationLock(t.Context(), dir)
Expand Down
Loading
Loading