Files
felhom-controller/controller/internal/stacks/allowlist_wiring_test.go
T
admin 6d90374f63
gates / gates (push) Successful in 45s
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)
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
2026-10-17 16:34:12 +02:00

169 lines
6.8 KiB
Go

package stacks
import (
"go/ast"
"go/parser"
"go/token"
"path/filepath"
"sort"
"strings"
"testing"
)
// TestAllowList_IsWiredIntoEveryComposeWriter (R-971) — the catalog allow-list is only a control if EVERY path that
// writes an app's compose asks it first. Walked as an AST (comments dropped, so a commented-out call does not count),
// the shape of TestAdmission_IsWiredIntoEveryProductionWriteLeg.
//
// 1. DISCOVERY: every production function that names the compose file (the literal "docker-compose.yml", a
// ComposePathIn / AppliedComposePath call, a .ComposePath field) AND calls a file write must be in `writers` —
// either GATED (its first call of the gate comes before its first write) or EXEMPT with the reason. A new writer
// fails this test until someone decides which it is.
// 2. DECISION POINTS: the paths that choose an app's env (install, the two settings changes, the two restores) ask
// the gate before their first mutation.
//
// RED-PROOF (felhom.eu audits/agent-allowlist-rotation-2026-10-17/P2/redproof-allowlist.txt): remove the admit call
// from copyTemplates → step 1 fails naming it; add a new function that writes ComposePathIn(dir) → step 1 fails as
// an unknown writer.
func TestAllowList_IsWiredIntoEveryComposeWriter(t *testing.T) {
type rule struct {
gate string // "" = exempt
why string // exempt: the reason
before string // gated: the write the gate must precede (the first call of this name)
}
writers := map[string]rule{
"internal/sync/sync.go:copyTemplates": {gate: "admit", before: "writeIfChanged"},
"internal/stacks/pin.go:advancePinTo": {gate: "admitCompose", before: "StoreAppliedDefinition"},
"cmd/controller/main.go:RecreateStackDefinitionFromUnit": {gate: "AdmitDefinitionFromUnit", before: "WriteFile"},
"internal/stacks/infra.go:ensureFileBrowser": {why: "controller-rendered infra stack — out of scope"},
"internal/stacks/infra.go:reconcileFileBrowserImage": {why: "controller-rendered infra stack — out of scope"},
"internal/stacks/install_interrupted.go:RecoverInterruptedInstalls": {why: "writes the interrupted-install marker, no compose"},
"internal/stacks/pin.go:StoreAppliedDefinition": {why: "the STORED copy; it becomes live only through copyTemplates' frozen branch, which admits it"},
"internal/stacks/update.go:runGuardedUpdate": {why: "copies the PREVIOUS compose aside (the undo); the new one is written by advancePinTo"},
"internal/stacks/update.go:restoreDefinition": {why: "puts back the bytes the app ran on before the update — an undo adds nothing"},
"internal/backup/offbox_reconstitute.go:captureLiveDefinition": {why: "a scratch copy in a temp dir, never brought up"},
"internal/backup/recovery_unit.go:captureRecoveryUnit": {why: "the backup's copy; its way back in is RecreateStackDefinitionFromUnit (gated)"},
}
decisions := []struct{ fn, gate, before string }{
{"internal/stacks/deploy.go:DeployStack", "admitCompose", "SaveAppConfig"},
{"internal/stacks/deploy.go:UpdateStackConfig", "admitWithNewValues", "SaveAppConfig"},
{"internal/stacks/deploy.go:UpdateOptionalConfig", "admitWithNewValues", "SaveAppConfig"},
{"internal/backup/restore_unit.go:RestoreFromRecoveryUnitAtWith", "admitRestoredDefinition", "StopStack"},
{"internal/backup/offbox_reconstitute.go:ReconstituteFromOffsite", "admitRestoredDefinition", "StopStack"},
}
writes := map[string]bool{"WriteFile": true, "writeIfChanged": true, "copyIfChanged": true, "atomicWrite": true,
"Rename": true, "Create": true, "OpenFile": true, "StoreAppliedDefinition": true, "copyFile": true, "CopyFile": true}
root := filepath.Join("..", "..")
calls := map[string][]string{} // "dir/file.go:Func" → called names in source order
var found []string
for _, dir := range []string{"internal/stacks", "internal/sync", "internal/backup", "internal/appexport",
"internal/web", "internal/api", "cmd/controller"} {
files, _ := filepath.Glob(filepath.Join(root, dir, "*.go"))
if len(files) == 0 {
t.Fatalf("no Go files under %s — the walk cannot see the code it guards", dir)
}
for _, f := range files {
if strings.HasSuffix(f, "_test.go") {
continue
}
fset := token.NewFileSet()
file, err := parser.ParseFile(fset, f, nil, 0)
if err != nil {
t.Fatal(err)
}
for _, d := range file.Decls {
fd, ok := d.(*ast.FuncDecl)
if !ok || fd.Body == nil {
continue
}
id := dir + "/" + filepath.Base(f) + ":" + fd.Name.Name
names, compose, writesFile := []string{}, false, false
ast.Inspect(fd.Body, func(n ast.Node) bool {
switch v := n.(type) {
case *ast.SelectorExpr:
if v.Sel.Name == "ComposePath" {
compose = true
}
case *ast.BasicLit:
if v.Value == `"docker-compose.yml"` {
compose = true
}
case *ast.CallExpr:
name := ""
switch fn := v.Fun.(type) {
case *ast.Ident:
name = fn.Name
case *ast.SelectorExpr:
name = fn.Sel.Name
}
if name == "ComposePathIn" || name == "AppliedComposePath" {
compose = true
}
if writes[name] {
writesFile = true
}
if name != "" {
names = append(names, name)
}
}
return true
})
calls[id] = names
if compose && writesFile {
found = append(found, id)
}
}
}
}
// 1. Discovery.
sort.Strings(found)
for _, id := range found {
r, known := writers[id]
if !known {
t.Errorf("%s writes a file and names the compose file, and the allow-list guard does not know it — gate it "+
"(admitCompose before the write) or add it to `writers` as exempt with the reason", id)
continue
}
if r.gate == "" {
continue
}
assertCalledBefore(t, id, calls[id], r.gate, r.before)
}
for id := range writers {
if _, ok := calls[id]; !ok {
t.Errorf("fixture drift: %s no longer exists — this guard can no longer see that writer", id)
}
}
// 2. Decision points.
for _, d := range decisions {
c, ok := calls[d.fn]
if !ok {
t.Errorf("fixture drift: %s no longer exists", d.fn)
continue
}
assertCalledBefore(t, d.fn, c, d.gate, d.before)
}
}
func assertCalledBefore(t *testing.T, id string, calls []string, gate, act string) {
t.Helper()
gi, ai := -1, -1
for i, c := range calls {
if c == gate && gi < 0 {
gi = i
}
if c == act && ai < 0 {
ai = i
}
}
switch {
case gi < 0:
t.Errorf("%s: the allow-list gate %q is never called", id, gate)
case ai < 0:
t.Errorf("fixture drift: %s no longer calls %q — the guard cannot order the gate against it", id, act)
case gi > ai:
t.Errorf("%s: %q first appears after %q — the check must come before the write", id, gate, act)
}
}