diff --git a/cmd/pulse-agent/main.go b/cmd/pulse-agent/main.go index 2e694476f..969d290cb 100644 --- a/cmd/pulse-agent/main.go +++ b/cmd/pulse-agent/main.go @@ -1526,6 +1526,16 @@ func (b *lateBoundDockerUpdater) TypedContainerUpdate(ctx context.Context, runti return updater.TypedContainerUpdate(ctx, runtime, containerID, expectedImageDigest, progress) } +func (b *lateBoundDockerUpdater) TypedContainerUpdatePreflight(ctx context.Context, runtime, containerID, expectedImageDigest string) error { + b.mu.RLock() + updater := b.updater + b.mu.RUnlock() + if updater == nil { + return agentexec.NewActionPreflightError(agentexec.ActionRefusalCapabilityUnavailable, fmt.Errorf("docker module is not running on this agent")) + } + return updater.TypedContainerUpdatePreflight(ctx, runtime, containerID, expectedImageDigest) +} + func initDockerWithRetry(ctx context.Context, cfg dockeragent.Config, logger *zerolog.Logger) RunnableCloser { return initModuleWithRetry(ctx, logger, "docker_agent", "Docker", "Docker not available, will retry", func() (RunnableCloser, error) { return newDockerAgent(cfg) diff --git a/cmd/pulse-agent/main_test.go b/cmd/pulse-agent/main_test.go index daa0f628a..28c62c92c 100644 --- a/cmd/pulse-agent/main_test.go +++ b/cmd/pulse-agent/main_test.go @@ -2301,10 +2301,16 @@ func TestSecureAgentStateDir(t *testing.T) { type stubTypedContainerUpdater struct { calls int + preflightCalls int lifecycleInspects int lifecycleMutations int } +func (s *stubTypedContainerUpdater) TypedContainerUpdatePreflight(context.Context, string, string, string) error { + s.preflightCalls++ + return nil +} + func (s *stubTypedContainerUpdater) TypedContainerUpdate(context.Context, string, string, string, func(string)) (agentexec.DockerContainerUpdateOutcome, error) { s.calls++ return agentexec.DockerContainerUpdateOutcome{Success: true}, nil @@ -2322,19 +2328,35 @@ func (s *stubTypedContainerUpdater) MutateDockerContainerLifecycle(context.Conte func TestLateBoundDockerUpdaterBridgesModuleWhenItComesUp(t *testing.T) { bridge := &lateBoundDockerUpdater{} + containerID := strings.Repeat("a", 12) + expectedImageDigest := "sha256:" + strings.Repeat("1", 64) - if _, err := bridge.TypedContainerUpdate(context.Background(), "docker", strings.Repeat("a", 12), "sha256:"+strings.Repeat("1", 64), nil); err == nil { + if err := bridge.TypedContainerUpdatePreflight(context.Background(), "docker", containerID, expectedImageDigest); err == nil { + t.Fatal("bridge without a docker module accepted a preflight") + } else if got := agentexec.ActionPreflightReasonCode(err, agentexec.ActionRefusalTargetPreconditionFailed); got != agentexec.ActionRefusalCapabilityUnavailable { + t.Fatalf("bridge without a docker module refusal = %q, want %q", got, agentexec.ActionRefusalCapabilityUnavailable) + } + if _, err := bridge.TypedContainerUpdate(context.Background(), "docker", containerID, expectedImageDigest, nil); err == nil { t.Fatal("bridge without a docker module accepted an update") } bridge.set(struct{}{}) // non-implementing candidates must not install - if _, err := bridge.TypedContainerUpdate(context.Background(), "docker", strings.Repeat("a", 12), "sha256:"+strings.Repeat("1", 64), nil); err == nil { + if err := bridge.TypedContainerUpdatePreflight(context.Background(), "docker", containerID, expectedImageDigest); err == nil { + t.Fatal("bridge accepted a preflight after a non-implementing candidate was offered") + } + if _, err := bridge.TypedContainerUpdate(context.Background(), "docker", containerID, expectedImageDigest, nil); err == nil { t.Fatal("bridge accepted an update after a non-implementing candidate was offered") } stub := &stubTypedContainerUpdater{} bridge.set(stub) - if _, err := bridge.TypedContainerUpdate(context.Background(), "docker", strings.Repeat("a", 12), "sha256:"+strings.Repeat("1", 64), nil); err != nil { + if err := bridge.TypedContainerUpdatePreflight(context.Background(), "docker", containerID, expectedImageDigest); err != nil { + t.Fatalf("bridge preflight with an installed module refused: %v", err) + } + if stub.preflightCalls != 1 { + t.Fatalf("expected one delegated preflight call, got %d", stub.preflightCalls) + } + if _, err := bridge.TypedContainerUpdate(context.Background(), "docker", containerID, expectedImageDigest, nil); err != nil { t.Fatalf("bridge with an installed module refused: %v", err) } if stub.calls != 1 { diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 94ef5cc03..b925bec97 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -2623,6 +2623,18 @@ Agent` secondary handoff against the live setup wizard instead of relying ## Current State +### Docker update preflight and execution share one late-bound capability + +The unified agent's late-bound Docker updater now carries the read-only typed +container-update preflight and the corresponding execution method in one +required interface. The bridge delegates both methods to the running Docker / +Podman module and fails closed while that module is unavailable. This prevents +the command client from advertising a false `agent_capability_unavailable` +refusal merely because the process-lifetime bridge omitted the preflight +method, and makes future bridge drift a compile-time failure. The bridge proof +in `cmd/pulse-agent/main_test.go` covers unavailable, invalid-candidate, and +connected-module states for both preflight and execution. + ### Monitoring task limits do not change lifecycle authority The monitoring-owned process-wide scheduled-task limiter may bound host and diff --git a/docs/release-control/v6/internal/subsystems/security-privacy.md b/docs/release-control/v6/internal/subsystems/security-privacy.md index 39cff48fb..4e3d317f6 100644 --- a/docs/release-control/v6/internal/subsystems/security-privacy.md +++ b/docs/release-control/v6/internal/subsystems/security-privacy.md @@ -690,6 +690,16 @@ tokens, and path-normalization variants. ## Current State +### Docker update preflight remains read-only across the unified-agent bridge + +The `pulse-agent` late-bound Docker updater delegates the typed, read-only +container-update preflight through the same required capability as execution. +The preflight checks runtime identity, container identity, and expected image +digest without mutating the workload; adding it to the bridge restores the +existing approval boundary and grants no new command, inventory, environment, +mount, file, process, or guest-inspection authority. An unavailable Docker / +Podman module continues to fail closed. + ### Historical credential containment is prerelease-blocking The `historical-credential-containment` release gate now owns the cross-repo diff --git a/internal/hostagent/commands.go b/internal/hostagent/commands.go index 82ec72306..0e4f9191a 100644 --- a/internal/hostagent/commands.go +++ b/internal/hostagent/commands.go @@ -751,11 +751,10 @@ func (c *CommandClient) preflightDockerLifecycle(ctx context.Context, payload ag } func (c *CommandClient) preflightDockerUpdate(ctx context.Context, payload agentexec.DockerContainerUpdatePayload) (bool, string) { - preflighter, ok := c.dockerUpdater.(DockerContainerUpdatePreflighter) - if !ok || preflighter == nil { + if c.dockerUpdater == nil { return false, agentexec.ActionRefusalCapabilityUnavailable } - if err := preflighter.TypedContainerUpdatePreflight(ctx, payload.Runtime, payload.ContainerID, payload.ExpectedImageDigest); err != nil { + if err := c.dockerUpdater.TypedContainerUpdatePreflight(ctx, payload.Runtime, payload.ContainerID, payload.ExpectedImageDigest); err != nil { return false, agentexec.ActionPreflightReasonCode(err, agentexec.ActionRefusalTargetPreconditionFailed) } return true, "" diff --git a/internal/hostagent/docker_lifecycle_test.go b/internal/hostagent/docker_lifecycle_test.go index 21bfe3988..f38aab4fd 100644 --- a/internal/hostagent/docker_lifecycle_test.go +++ b/internal/hostagent/docker_lifecycle_test.go @@ -160,6 +160,10 @@ type stubDockerUpdater struct { calls *int } +func (stubDockerUpdater) TypedContainerUpdatePreflight(context.Context, string, string, string) error { + return nil +} + func (s stubDockerUpdater) TypedContainerUpdate(_ context.Context, _, _, _ string, _ func(string)) (agentexec.DockerContainerUpdateOutcome, error) { if s.calls != nil { *s.calls++ diff --git a/internal/hostagent/docker_update.go b/internal/hostagent/docker_update.go index 01b85e8f3..cd72f2443 100644 --- a/internal/hostagent/docker_update.go +++ b/internal/hostagent/docker_update.go @@ -18,11 +18,8 @@ import ( // missing, runtime mismatch, preflight drift) as opposed to update failures, // which arrive inside the outcome. type DockerContainerUpdater interface { - TypedContainerUpdate(ctx context.Context, runtime, containerID, expectedImageDigest string, progress func(string)) (agentexec.DockerContainerUpdateOutcome, error) -} - -type DockerContainerUpdatePreflighter interface { TypedContainerUpdatePreflight(ctx context.Context, runtime, containerID, expectedImageDigest string) error + TypedContainerUpdate(ctx context.Context, runtime, containerID, expectedImageDigest string, progress func(string)) (agentexec.DockerContainerUpdateOutcome, error) } func (c *CommandClient) handleDockerContainerUpdate(ctx context.Context, conn *websocket.Conn, payload agentexec.DockerContainerUpdatePayload) {