v0.328.0: the catalog allow-list — the box refuses container rights a template asks for and the box does not give (R-971, D2 = A)
gates / gates (push) Successful in 45s

internal/privpolicy checks the RENDERED compose (privileged, cap_add, devices, host namespaces, security_opt,
sysctls, volumes_from, build/include/extends, env_file/config files outside the stack dir, aliasing named volumes,
binds outside the box's data roots, the Docker socket, propagation, ${VAR} in a privileged key) against an
allow-list compiled into the controller (one entry: homepage's read-only socket). Asked before every compose
write: the 15-minute sync, the guarded update/ladder step, the install, both settings changes, both restores
(before the stop). A refusal writes nothing, keeps the app on its last good compose, raises the operator-only
event app_definition_refused once per definition and puts one line on the app page. The sync never copies a
template named like a controller infra stack. Structural guard TestAllowList_IsWiredIntoEveryComposeWriter.

Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-10-17 16:34:12 +02:00
parent 71fbef6bbe
commit 6d90374f63
26 changed files with 2408 additions and 9 deletions
+12 -3
View File
@@ -189,9 +189,12 @@ type Stack struct {
HoldNoWholeCopy bool `json:"hold_no_whole_copy,omitempty"`
// HoldKind (v0.269.0): which hold — "update_failed", "unhealthy_stop" (decision 28: the page offers
// Start, not a restore), "restore"; "" when none.
HoldKind string `json:"hold_kind,omitempty"`
HealthProbe *HealthProbeResult `json:"health_probe,omitempty"` // controller-side probe result
LastUpdated time.Time `json:"last_updated"`
HoldKind string `json:"hold_kind,omitempty"`
// DefinitionRefused (R-971) is the box's refusal of the app's newest catalog definition — the app keeps its last
// good compose. Filled on every read (allowlist.go); "" when none.
DefinitionRefused string `json:"definition_refused,omitempty"`
HealthProbe *HealthProbeResult `json:"health_probe,omitempty"` // controller-side probe result
LastUpdated time.Time `json:"last_updated"`
// RestartingSince (C9-F2) is when this stack was FIRST observed in StateRestarting during the
// current restarting run; zero whenever the stack is in any other state. It is what turns a brief
// restart (normal: deploy, update, quiesce restart) into a distinguishable crash loop — see
@@ -267,6 +270,10 @@ type Manager struct {
// this and nothing else: it used to be sent beside the 202 that merely accepted the request, so
// an install interrupted five seconds later stayed on the timeline as a completed one.
deployDoneHook func(name string, ok bool, detail string)
// defRefusedHook (R-971) raises the operator event for a definition the allow-list refused (allowlist.go).
defRefusedHook func(name, detail string)
defRefusedMu sync.Mutex
defRefused map[string]defRefusal
testSeams *migSeams // nil in production; tests inject fakes
// R-51: docker restart policies for DOWN members of mixed stacks. Keyed by
// containerName+"|"+state so a transitioned or recreated container re-reads rather than
@@ -1123,6 +1130,7 @@ func (m *Manager) GetStacks() []Stack {
m.mu.RUnlock()
for i := range result {
fillHoldReason(g, &result[i])
result[i].DefinitionRefused = m.definitionRefused(result[i].Name)
}
// Sort alphabetically by display name for consistent UI ordering
@@ -1143,6 +1151,7 @@ func (m *Manager) GetStack(name string) (*Stack, bool) {
}
cp := deepCopyStack(s)
fillHoldReason(m.updateGuards, &cp)
cp.DefinitionRefused = m.definitionRefused(name)
return &cp, true
}