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
+18
View File
@@ -464,6 +464,16 @@ func (m *Manager) DeployStack(req DeployRequest) (string, error) {
m.logger.Printf("[ERROR] [stacks] Deploy %s REFUSED: %v", req.StackName, err)
return "", util.MsgError("err.stacks.needs_newer_controller", err.Error())
}
// R-971: the compose, rendered with THIS install's values, must pass the box's catalog allow-list — refused before
// anything is written.
if data, rerr := os.ReadFile(stack.ComposePath); rerr != nil {
clearDeploying()
return "", fmt.Errorf("reading the compose file of %s: %w", req.StackName, rerr)
} else if aerr := m.admitCompose(req.StackName, data, env); aerr != nil {
clearDeploying()
m.logger.Printf("[ERROR] [stacks] Deploy %s REFUSED by the allow-list: %v", req.StackName, aerr)
return "", util.MsgError("err.stacks.definition_refused", req.StackName, aerr.Error())
}
// `09` §3 decision 46: a gated template is installed CLOSED, and the gate's traefik file is written BEFORE
// the first start (spike F2). Cannot write it → the install is refused: never published open.
var gate *SetupGateRecord
@@ -746,6 +756,10 @@ func (m *Manager) UpdateStackConfig(name string, values map[string]string) error
if m.isDebug() {
m.logger.Printf("[DEBUG] [stacks] UpdateStackConfig %s: changed keys: [%s], locked keys: %d", name, strings.Join(changedKeys, ", "), len(lockedSet))
}
// R-971: the compose, rendered with the NEW values, must still pass the box's allow-list — refused before the save.
if err := m.admitWithNewValues(name, stackDir, values); err != nil {
return util.MsgError("err.stacks.definition_refused", name, err.Error())
}
if err := SaveAppConfig(stackDir, appCfg, m.encKey, SensitiveEnvVars(&meta)); err != nil {
return fmt.Errorf("saving updated config: %w", err)
@@ -989,6 +1003,10 @@ func (m *Manager) UpdateOptionalConfig(stackName string, values map[string]strin
if !changed {
return nil
}
// R-971: the compose, rendered with the NEW values, must still pass the box's allow-list — refused before the save.
if err := m.admitWithNewValues(stackName, stackDir, values); err != nil {
return util.MsgError("err.stacks.definition_refused", stackName, err.Error())
}
// Save app.yaml
meta := LoadMetadata(stackDir)