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
+21
View File
@@ -418,6 +418,7 @@ func main() {
// the pre-v0.235.0 product; the freeze is INERT unless it is wired here, which is why a test
// walks this file's AST for the call rather than trusting the package to be correct alone.
syncer.SetRenderPlanFn(stackMgr.RenderPlanFor)
syncer.SetAdmitFn(stackMgr.AdmitSyncedCompose) // R-971: the catalog allow-list, before any compose is written
// Start() is deliberately NOT called here — see the comment beside the call further down, after
// the pin adoption pass.
defer syncer.Stop()
@@ -2001,6 +2002,12 @@ func main() {
// `app_deploy_started`, emitted by the API router beside its 202.
// v0.279.0: the hook is set on EVERY box — after_install (decision 45) must run on a box with no hub too;
// only the events need the notifier.
// R-971: a definition the catalog allow-list refused raises the operator-only event (once per definition).
stackMgr.SetDefinitionRefusedHook(func(name, detail string) {
if notifier != nil {
notifier.NotifyAppDefinitionRefused(name, detail)
}
})
stackMgr.SetDeployDoneHook(func(name string, ok bool, detail string) {
display := name
if s, found := stackMgr.GetStack(name); found && s.Meta.DisplayName != "" {
@@ -3228,6 +3235,10 @@ func (a *stackAdapter) RecreateStackDefinitionFromUnit(name, composeSrcDir strin
return fmt.Errorf("stack %q not found", name)
}
stackDir := filepath.Dir(s.ComposePath)
// R-971: the writer asks too (the restores asked before they stopped the app; this is the last word).
if err := a.AdmitDefinitionFromUnit(name, composeSrcDir, fullEnv); err != nil {
return err
}
// Recover the app definition from the unit (compose + .felhom.yml) into the stack dir.
for _, fname := range []string{"docker-compose.yml", ".felhom.yml"} {
data, err := os.ReadFile(filepath.Join(composeSrcDir, fname))
@@ -3265,6 +3276,16 @@ func (a *stackAdapter) RecreateStackDefinitionFromUnit(name, composeSrcDir strin
return nil
}
// AdmitDefinitionFromUnit (R-971) checks a unit's compose, rendered with the restore's env, against the box's catalog
// allow-list. No compose in the unit → nothing would be written → nil.
func (a *stackAdapter) AdmitDefinitionFromUnit(name, composeSrcDir string, fullEnv map[string]string) error {
data, err := os.ReadFile(filepath.Join(composeSrcDir, "docker-compose.yml"))
if err != nil {
return nil
}
return a.mgr.AdmitCompose(name, data, fullEnv)
}
// SetStackSecretValues writes the named values into the app's app.yaml (R-1106: the off-site restore's database
// password). The optional backup.secretValueSetter.
func (a *stackAdapter) SetStackSecretValues(name string, values map[string]string) error {