v0.329.0: each app reaches only its own appdata folder; empty allow-list; drive retirement asks the agent first; plain no-durable-id refusal; :latest only for a release
gates / gates (push) Successful in 45s

R-1142: the HDD_PATH root is appdata/<own folder>, with a compiled-in folder map (paperless-ngx ->
paperless). R-981: the allow-list is empty (homepage's socket served no Felhom feature). R-1063:
finalizeDecommissionWith asks the agent before it marks the drive or moves the default; the 403/409
come back typed and reach the household as a plain 409. R-1069: the agent's no-durable-id format
refusal is ErrNoDurableID and a plain sentence. R-1143: build.sh carries :latest only for x.y.z.
Each pinned by a red-proofed test. MinAgent 0.158.1.
This commit is contained in:
admin committed 2026-10-17 23:11:56 +02:00
1 parent 5d67041d6d
commit 2fe634141f
20 files changed
+504 -78

No files matched your search

+42 -9
View File
@@ -21,7 +21,9 @@
//
// The data roots are the values the controller itself gives the app: HDD_PATH, USERDATA_PATH and IMPORT_PATH, each
// an absolute clean path under /mnt/ or under the system data path. A bind source may use only those three variables,
// and only as its leading token.
// and only as its leading token. Under HDD_PATH the root is the app's OWN folder, ${HDD_PATH}/appdata/<folder>/ (R-1142):
// <folder> is the app's name, or its entry in the list's appdata_folders (paperless-ngx keeps appdata/paperless) — so a
// catalog push cannot hand one app another app's files.
//
// The controller's own infra stacks (traefik, cloudflared, filebrowser, samba) and its override files (setup gate,
// family gate, install hold, media library) are rendered by the controller, not the catalog: out of scope.
@@ -69,11 +71,14 @@ type Entry struct {
type allowList struct {
Version int `json:"version"`
Entries []Entry `json:"entries"`
// AppdataFolders names an app's folder under ${HDD_PATH}/appdata/ when it is not the app's own name (R-1142). It is
// compiled in with the entries for the same reason: the catalog — the threat — must not be able to name the folder.
AppdataFolders map[string]string `json:"appdata_folders"`
}
var allowed = mustLoad(allowListJSON)
var allowed, appdataFolders = mustLoad(allowListJSON)
func mustLoad(b []byte) map[string]Entry {
func mustLoad(b []byte) (map[string]Entry, map[string]string) {
var l allowList
if err := json.Unmarshal(b, &l); err != nil {
panic("privpolicy: allowlist.json does not parse: " + err.Error())
@@ -85,7 +90,29 @@ func mustLoad(b []byte) map[string]Entry {
}
m[key(e.App, e.Service, e.Setting)] = e
}
return m
for app, f := range l.AppdataFolders {
if !oneSegment(f) {
panic("privpolicy: allowlist.json appdata folder of " + app + " is not one folder name: " + f)
}
}
return m, l.AppdataFolders
}
// oneSegment: a single folder name — no separator, not "." or "..", not empty.
func oneSegment(s string) bool {
return s != "" && s != "." && s != ".." && !strings.ContainsAny(s, "/\\$")
}
// AppdataFolder is the app's own folder under ${HDD_PATH}/appdata/ (R-1142): its appdata_folders entry, else its name.
// "" when the app has no usable name — then HDD_PATH gives it no root at all (fail closed).
func AppdataFolder(app string) string {
if f, ok := appdataFolders[app]; ok {
return f
}
if !oneSegment(app) {
return ""
}
return app
}
func key(app, svc, setting string) string { return app + "\x00" + svc + "\x00" + setting }
@@ -156,7 +183,7 @@ func PlaceholderEnv() map[string]string {
// error = the compose could not be read, which refuses too (fail closed: a definition the check cannot read is not a
// definition it has checked).
func Admit(app string, compose []byte, env map[string]string, p Policy) error {
vs, err := Check(compose, env, p)
vs, err := Check(app, compose, env, p)
if err != nil {
return fmt.Errorf("the catalog definition of %s could not be checked: %w", app, err)
}
@@ -230,13 +257,13 @@ func imageRepo(ref string) string {
return ref
}
// Check lists every setting in a class, allowed or not, sorted.
func Check(compose []byte, env map[string]string, p Policy) ([]Violation, error) {
// Check lists every setting in a class, allowed or not, sorted. app decides the HDD_PATH root (R-1142).
func Check(app string, compose []byte, env map[string]string, p Policy) ([]Violation, error) {
var doc map[string]any
if err := yaml.Unmarshal(compose, &doc); err != nil {
return nil, err
}
c := &checker{env: env, p: p}
c := &checker{env: env, p: p, app: app}
c.roots = c.validRoots()
if _, ok := doc["include"]; ok {
c.add("", "include")
@@ -276,6 +303,7 @@ func Check(compose []byte, env map[string]string, p Policy) ([]Violation, error)
}
type checker struct {
app string
env map[string]string
p Policy
roots []string
@@ -516,7 +544,12 @@ func (c *checker) validRoots() []string {
// round 3: HDD_PATH is the drive's whole namespace — its backups/ (recovery units carry app secrets)
// and every other top-level folder are siblings an app has no business binding. Only appdata/ is a
// root; the household's folders come through USERDATA_PATH.
v = filepath.Join(v, "appdata")
// R-1142: and only the app's OWN folder in it — appdata/<other app>/ is another app's files.
f := AppdataFolder(c.app)
if f == "" {
continue
}
v = filepath.Join(v, "appdata", f)
}
out = append(out, v)
}