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/+application-startup-verifier.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
kind: Security
body: Package a trusted startup verifier into application images and fail closed unless seccomp, no-new-privileges, and empty capability sets are active before workload execution.
18 changes: 15 additions & 3 deletions docs/CONTROLLED_SESSION_DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ summary: Capability-scoped execution sessions that inherit Reploy's global conta
## Status

- Decision state: Focused review complete; high-level decisions approved
- Implementation state: Not started
- Implementation state: Initial global sandbox prerequisites and trusted
application-startup verification implemented in the current slice;
controlled-session authorization, protocol, lifecycle, and Docker
orchestration remain later slices
- Initial runtime: Linux containers under Docker
- Motivating clients: OmegaFlow recording, sandboxed AI agents, security
inspection, and untrusted-code execution
Expand Down Expand Up @@ -1046,8 +1049,17 @@ commands directly as the final identity, drops all capabilities, enables
`no-new-privileges`, explicitly selects Docker's built-in seccomp profile, and
prohibits privileged mode, host namespaces, and host devices in the common
plan. Live Docker tests inspect both runtime paths. Trusted production startup
verification, mount/root authority, network denial, and resource limits remain
separate prerequisite slices.
verification is also implemented: Reploy packages the platform-specific probe
in a final runtime layer, records that layer outside the provider graph, and
uses its fixed verify-and-exec contract as the outermost process for persistent
workloads, transient commands, shells, and lifecycle commands. The verifier
fails closed unless `/proc/self/status` reports seccomp filtering,
`no-new-privileges`, and empty effective, permitted, and bounding capability
sets, then directly executes the exact application argv. Private-environment
workloads use one additional fixed Reploy step: after verification, the probe
executes the environment injector, which imports the private variables and then
executes the unchanged application argv. Mount/root authority, network denial,
and resource limits remain separate prerequisite slices.

### Slice 2: Controlled-Session Lifecycle Core

Expand Down
18 changes: 15 additions & 3 deletions internal/deploy/build_lock.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ type BuildLockV1 struct {
Nodes []NodeLockV1 `json:"nodes"`
Catalog []providers.RealizedOutput `json:"catalog"`
RuntimePolicy RuntimePolicyV1 `json:"runtime_policy"`
RuntimeLayer ApplicationRuntimeLayerV1 `json:"runtime_layer"`

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 Bump or migrate lock-v1 before requiring verifier fields

Existing deployments have build locks already written with schema: "lock-v1" that do not contain the new runtime_layer field (and their embedded runtime policy also lacks startup_verifier). After this change, ReadBuildLock still decodes them as lock-v1 but DecodeBuildLockV1/ValidateBuildLockV1 rejects the zero-value verifier/runtime layer, and ValidateCurrentBuild propagates that error from build preparation instead of treating the old current build as stale. That means an upgraded user with a pre-verifier current generation can be blocked before Reploy gets a chance to rebuild the image with the new verifier; please add a schema migration/version bump or explicitly classify pre-verifier locks as rebuildable stale state.

Useful? React with 👍 / 👎.

ValidationRecord providerstore.StoreObjectRef `json:"validation_record"`
FinalImage providers.RealizedImageV1 `json:"final_image"`
}
Expand Down Expand Up @@ -152,6 +153,14 @@ func ValidateBuildLockV1(lock BuildLockV1, validateProfileOwner providers.Requir
if err := ValidateRuntimePolicyV1(lock.RuntimePolicy); err != nil {
return fmt.Errorf("build lock runtime policy: %w", err)
}
if err := ValidateApplicationRuntimeLayerV1(lock.RuntimeLayer, lock.Platform); err != nil {
return fmt.Errorf("build lock runtime layer: %w", err)
}
if lock.RuntimeLayer.Verifier.Schema != lock.RuntimePolicy.StartupVerifier.Schema ||
lock.RuntimeLayer.Verifier.RecipeVersion != lock.RuntimePolicy.StartupVerifier.RecipeVersion ||
lock.RuntimeLayer.Verifier.Path != lock.RuntimePolicy.StartupVerifier.Path {
return fmt.Errorf("build lock runtime layer verifier does not match the runtime policy")
}
if err := lock.ValidationRecord.Validate(); err != nil {
return fmt.Errorf("build lock validation record: %w", err)
}
Expand Down Expand Up @@ -202,10 +211,13 @@ func validateBuildLockImageLineage(lock BuildLockV1) error {
}
current = node.Result
}
if lock.FinalImage.RootFSSubject != current.RootFSSubject {
if lock.RuntimeLayer.Upstream != current {
return fmt.Errorf("build lock runtime layer upstream does not match the final graph prefix")
}
if lock.FinalImage.RootFSSubject != lock.RuntimeLayer.Result.RootFSSubject {
return fmt.Errorf(
"build lock final image root filesystem %s does not match the final graph prefix %s",
lock.FinalImage.RootFSSubject, current.RootFSSubject,
"build lock final image root filesystem %s does not match the application runtime layer %s",
lock.FinalImage.RootFSSubject, lock.RuntimeLayer.Result.RootFSSubject,
)
}
return nil
Expand Down
26 changes: 23 additions & 3 deletions internal/deploy/build_lock_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,15 +26,28 @@ func validBuildLock(t *testing.T) BuildLockV1 {
if err != nil {
t.Fatal(err)
}
upstream := providers.RealizedImageV1{Digest: base.ConfigDigest, ConfigDigest: base.ConfigDigest, RootFSSubject: baseRootFS}
verifier := ApplicationStartupVerifierContractV1()
verifier.Artifact = buildLockTestDigest("6")
verifier.Size = "123"
transaction, err := ApplicationRuntimeLayerTransactionDigestV1(verifier, upstream, platform)
if err != nil {
t.Fatal(err)
}
result := providers.RealizedImageV1{Digest: buildLockTestDigest("5"), ConfigDigest: buildLockTestDigest("5"), RootFSSubject: buildLockTestDigest("7")}
return BuildLockV1{
Schema: BuildLockSchemaV1, BlueprintDigest: buildLockTestDigest("0"), Overlay: EmptyRequestOverlayV1(),
PackageOverrides: EmptyPackageOverrideIntentV1("demo"),
ResolvedRequestDigest: buildLockTestDigest("1"), Platform: platform,
Base: base,
Graph: ProviderGraphLockV1{Nodes: []providers.NodeID{"base"}, Edges: []providers.ProviderEdgeV1{}},
Nodes: []NodeLockV1{}, Catalog: []providers.RealizedOutput{}, RuntimePolicy: validRuntimePolicy(),
RuntimeLayer: ApplicationRuntimeLayerV1{
Schema: ApplicationRuntimeLayerSchemaV1, Verifier: verifier, TransactionDigest: transaction,
Upstream: upstream, Result: result,
},
ValidationRecord: providerstore.StoreObjectRef{Kind: providerstore.ValidationRecordKind, Digest: buildLockTestDigest("4")},
FinalImage: providers.RealizedImageV1{Digest: buildLockTestDigest("5"), ConfigDigest: buildLockTestDigest("5"), RootFSSubject: baseRootFS},
FinalImage: result,
}
}

Expand Down Expand Up @@ -84,7 +97,11 @@ func addValidAPTNode(t *testing.T, lock *BuildLockV1) {
GeneratedExecutables: []providers.RealizedGeneratedExecutable{},
Outputs: []providers.RealizedOutput{},
}}
lock.FinalImage.RootFSSubject = result.RootFSSubject
lock.RuntimeLayer.Upstream = result
lock.RuntimeLayer.TransactionDigest, err = ApplicationRuntimeLayerTransactionDigestV1(lock.RuntimeLayer.Verifier, result, lock.Platform)
if err != nil {
t.Fatal(err)
}
}

func TestBuildLockV1CanonicalRoundTripAndIdentity(t *testing.T) {
Expand Down Expand Up @@ -160,7 +177,7 @@ func TestBuildLockV1RejectsDisconnectedImageLineage(t *testing.T) {
t.Run("final root filesystem", func(t *testing.T) {
lock := validBuildLock(t)
lock.FinalImage.RootFSSubject = buildLockTestDigest("6")
if _, err := BuildLockDigestV1(lock, acceptBuildLockProfile); err == nil || !strings.Contains(err.Error(), "does not match the final graph prefix") {
if _, err := BuildLockDigestV1(lock, acceptBuildLockProfile); err == nil || !strings.Contains(err.Error(), "does not match the application runtime layer") {
t.Fatalf("lineage error = %v", err)
}
})
Expand All @@ -179,6 +196,9 @@ func TestBuildLockV1RejectsInvalidNestedIdentity(t *testing.T) {
{name: "missing node lock", mutate: func(value *BuildLockV1) { value.Graph.Nodes = []providers.NodeID{"apt", "base"} }, want: "missing node"},
{name: "nil catalog", mutate: func(value *BuildLockV1) { value.Catalog = nil }, want: "catalog"},
{name: "runtime policy", mutate: func(value *BuildLockV1) { value.RuntimePolicy.Schema = "bad" }, want: "runtime policy"},
{name: "runtime verifier contract", mutate: func(value *BuildLockV1) { value.RuntimeLayer.Verifier.Path = "/bin/true" }, want: "startup verifier"},
{name: "runtime layer transaction", mutate: func(value *BuildLockV1) { value.RuntimeLayer.TransactionDigest = buildLockTestDigest("f") }, want: "transaction digest"},
{name: "runtime layer upstream", mutate: func(value *BuildLockV1) { value.RuntimeLayer.Upstream.ConfigDigest = buildLockTestDigest("f") }, want: "final graph prefix"},
{name: "validation kind", mutate: func(value *BuildLockV1) { value.ValidationRecord.Kind = providerstore.BlobKind }, want: "validation-record"},
{name: "final image", mutate: func(value *BuildLockV1) { value.FinalImage.RootFSSubject = "bad" }, want: "final image"},
}
Expand Down
10 changes: 7 additions & 3 deletions internal/deploy/runtime_policy.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,10 @@ const (
)

type RuntimePolicyV1 struct {
Schema string `json:"schema"`
ProtectedPaths []ProtectedPathV1 `json:"protected_paths"`
Plans []RuntimePlanV1 `json:"plans"`
Schema string `json:"schema"`
StartupVerifier ApplicationStartupVerifierV1 `json:"startup_verifier"`
ProtectedPaths []ProtectedPathV1 `json:"protected_paths"`
Plans []RuntimePlanV1 `json:"plans"`
}

type ProtectedPathV1 struct {
Expand Down Expand Up @@ -63,6 +64,9 @@ func ValidateRuntimePolicyV1(policy RuntimePolicyV1) error {
if policy.ProtectedPaths == nil || policy.Plans == nil {
return fmt.Errorf("runtime policy collections must use arrays")
}
if err := ValidateApplicationStartupVerifierV1(policy.StartupVerifier, false); err != nil {
return fmt.Errorf("runtime policy startup verifier: %w", err)
}
for index, protected := range policy.ProtectedPaths {
if err := validateRuntimeAbsolutePath("protected path", protected.Path); err != nil {
return err
Expand Down
2 changes: 1 addition & 1 deletion internal/deploy/runtime_policy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import (

func validRuntimePolicy() RuntimePolicyV1 {
return RuntimePolicyV1{
Schema: RuntimePolicySchemaV1,
Schema: RuntimePolicySchemaV1, StartupVerifier: ApplicationStartupVerifierContractV1(),
ProtectedPaths: []ProtectedPathV1{
{Path: "/.reploy", Kind: ProtectedPathReployRoot, Owner: "reploy"},
{Path: "/opt/app/bin/tool", Kind: ProtectedPathExecutablePath, Owner: "app.tool"},
Expand Down
102 changes: 102 additions & 0 deletions internal/deploy/runtime_verifier.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package deploy

import (
"fmt"
"strconv"

"github.com/omry/reploy/internal/blueprint"
"github.com/omry/reploy/internal/canonical"
"github.com/omry/reploy/internal/providers"
)

const (
ApplicationStartupVerifierSchemaV1 = "application-startup-verifier-v1"
ApplicationStartupVerifierRecipeV1 = "linux-proc-status-verify-exec-v1"
ApplicationStartupVerifierPathV1 = "/reploy-probe"
ApplicationRuntimeLayerSchemaV1 = "application-runtime-layer-v1"
)

type ApplicationStartupVerifierV1 struct {
Schema string `json:"schema"`
RecipeVersion string `json:"recipe_version"`
Path string `json:"path"`
Artifact canonical.Digest `json:"artifact"`
Size string `json:"size"`
}

type ApplicationRuntimeLayerV1 struct {
Schema string `json:"schema"`
Verifier ApplicationStartupVerifierV1 `json:"verifier"`
TransactionDigest canonical.Digest `json:"transaction_digest"`
Upstream providers.RealizedImageV1 `json:"upstream"`
Result providers.RealizedImageV1 `json:"result"`
}

func ApplicationStartupVerifierContractV1() ApplicationStartupVerifierV1 {
return ApplicationStartupVerifierV1{
Schema: ApplicationStartupVerifierSchemaV1, RecipeVersion: ApplicationStartupVerifierRecipeV1,
Path: ApplicationStartupVerifierPathV1,
}
}

func ValidateApplicationStartupVerifierV1(verifier ApplicationStartupVerifierV1, requireArtifact bool) error {
want := ApplicationStartupVerifierContractV1()
if verifier.Schema != want.Schema || verifier.RecipeVersion != want.RecipeVersion || verifier.Path != want.Path {
return fmt.Errorf("application startup verifier does not use the supported fixed contract")
}
if !requireArtifact {
if verifier.Artifact != "" || verifier.Size != "" {
return fmt.Errorf("application startup verifier policy contract must not contain an artifact")
}
return nil
}
if err := verifier.Artifact.Validate(); err != nil {
return fmt.Errorf("application startup verifier artifact: %w", err)
}
size, err := strconv.ParseUint(verifier.Size, 10, 64)
if err != nil || size == 0 || strconv.FormatUint(size, 10) != verifier.Size {
return fmt.Errorf("application startup verifier size must be a canonical positive integer")
}
return nil
}

func ApplicationRuntimeLayerTransactionDigestV1(
verifier ApplicationStartupVerifierV1,
upstream providers.RealizedImageV1,
platform blueprint.Platform,
) (canonical.Digest, error) {
if err := ValidateApplicationStartupVerifierV1(verifier, true); err != nil {
return "", err
}
if err := upstream.Validate(); err != nil {
return "", fmt.Errorf("application runtime layer upstream: %w", err)
}
if err := platform.Validate(); err != nil {
return "", fmt.Errorf("application runtime layer platform: %w", err)
}
return canonical.Sum("application-runtime-layer", ApplicationRuntimeLayerSchemaV1, struct {
Verifier ApplicationStartupVerifierV1 `json:"verifier"`
Upstream providers.RealizedImageV1 `json:"upstream"`
Platform blueprint.Platform `json:"platform"`
}{Verifier: verifier, Upstream: upstream, Platform: platform})
}

func ValidateApplicationRuntimeLayerV1(layer ApplicationRuntimeLayerV1, platform blueprint.Platform) error {
if layer.Schema != ApplicationRuntimeLayerSchemaV1 {
return fmt.Errorf("application runtime layer schema must be %q", ApplicationRuntimeLayerSchemaV1)
}
want, err := ApplicationRuntimeLayerTransactionDigestV1(layer.Verifier, layer.Upstream, platform)
if err != nil {
return err
}
if layer.TransactionDigest != want {
return fmt.Errorf("application runtime layer transaction digest does not match its inputs")
}
if err := layer.Result.Validate(); err != nil {
return fmt.Errorf("application runtime layer result: %w", err)
}
if layer.Result.RootFSSubject == layer.Upstream.RootFSSubject {
return fmt.Errorf("application runtime layer result must add the verifier filesystem layer")
}
return nil
}
Loading
Loading