From 63dcccfeecb99dad07fd23d262ba5bcfe7ddf65d Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Fri, 23 Oct 2026 13:09:52 +0200 Subject: [PATCH] =?UTF-8?q?v0.341.0:=20the=20night=20update=20takes=20a=20?= =?UTF-8?q?security=5Ffix=20ladder=20step=20(D39=20=3D=20B,=2009=20=C2=A73?= =?UTF-8?q?=20decision=20255,=20R-1239);=20every=20other=20leg=20guard=20u?= =?UTF-8?q?nchanged;=20the=20leg=20names=20the=20CVE=20ids;=20MinAgent=200?= =?UTF-8?q?.158.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012qRErfCoiTkvDK9N5XHbzb --- CHANGELOG.md | 22 ++++ CONTEXT.md | 6 + REPORT.md | 35 ++---- controller/README.md | 8 ++ controller/internal/stacks/ladder.go | 16 +++ controller/internal/stacks/unattended.go | 26 ++++- .../stacks/unattended_security_test.go | 108 ++++++++++++++++++ 7 files changed, 191 insertions(+), 30 deletions(-) create mode 100644 controller/internal/stacks/unattended_security_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index a3d8079..085f1a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,25 @@ +## v0.341.0 (2026-10-23) — the night update takes a security-fix step (D39 = B, `09` §3 decision 255, R-1239) + +**MinAgent: 0.158.1** (unchanged). No hub dependency (the hub report's `update_leg.steps[].security` is a new optional +field; the hub stores the report as it comes). Task: the D39 = B day brief (attended). + +- **`legCandidate`** takes a ladder step whose verdict is `proven` OR `security_fix` (`stacks.LadderVerdictSecurityFix`) — + an urgent upstream fix proven on the box (scratch 9202, the guarded Update), its bench run optional, its public notice in + the entry's `security` block (CVE/GHSA ids, the advisory, the first fixed version; `stacks.LadderSecurity`). Every other + guard is unchanged and runs in the same order: held, stopped by the household, current/ahead/unorderable, unpinned, no + ladder, older than the ladder, `needs_person`, the failed step (R-680), `files_may_change` without a whole copy, W+5h. +- The leg logs `[update-leg] : security fix taken ( → )` when it presses such a step (beside the + usual `step pressed`); the summary step carries `security: [ids]` (hub report) and the summary line ends + `[security fix : ]`. A skipped security step logs its reason like any skip. +- `LadderEntry.Security` is `omitempty`, so `LadderPrint` (R-680's fingerprint) of every existing entry is unchanged. +- **Unchanged:** a person's press (it never read the verdict — on 0.340.0 a hand press took the step; proven on 9202), + the guarded update, the badge. +- Tests (`internal/stacks/unattended_security_test.go`, the real leg over the real guarded update): taken and named; + `needs_person` skipped; held skipped; stopped by the household skipped; an unknown verdict still `not_proven`; `proven` + unchanged with no security names. Red-proofs: the OLD line turns the first two red; „a security_fix returns before the + guards" turns needs_person/held/stopped red; „any verdict but unrecorded is taken" turns the unknown-verdict test red; + „log every step as a security fix" turns the proven test red. + ## v0.340.0 (2026-10-23) — the night copy in a capped container that never outlives the controller (R-1214 + the stray-copy fix), Immich's model cache out of backups (R-49, D21 = B), no import over a stopped app (R-1187 c), the catch-up's checks (R-1189 d, C5) **MinAgent: 0.158.1** (unchanged). No hub dependency. Task: the 2026-10-23 day brief (release run, operator go). Soaked diff --git a/CONTEXT.md b/CONTEXT.md index 6b77ce5..037f8df 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -1,5 +1,11 @@ # CONTEXT.md — Project Memory +> **2026-10-23 (day, later) — v0.341.0: the night leg takes a `security_fix` ladder step (D39 = B, `09` §3 decision 255, +> R-1239; operator ruling by starting the run).** `legCandidate`: verdict `proven` OR `security_fix`, every other guard +> unchanged and in order; log `[update-leg] : security fix taken ( → )`; `LegStep.Security`. The +> catalog side (format, gates, writer, `box_walk.py --security`) is catalog `3898593`. On ≤ 0.340.0 the leg skips such a +> step (`not_proven`) and a hand press takes it — both proven on 9202 (felhom.eu `audits/security-lane-2026-10-23/`). + > **2026-10-22/23 night — 0.340.0 candidate on `main`, unreleased.** The stray-copy fix (`66a69d4`, `912e592`) and two > review rounds (`943c517` C5/C3/C6/C8; `9d9e404` N2–N5, N9; `1babfe9` two races): the capped copy's container has a > fixed name + label, is removed by name on any error, leftovers are removed at start before the scheduler/web server diff --git a/REPORT.md b/REPORT.md index 3b44eed..f554f25 100644 --- a/REPORT.md +++ b/REPORT.md @@ -1,28 +1,13 @@ -# REPORT — day 2026-10-23: controller 0.340.0 released (the operator's „go") +# REPORT — v0.341.0: the night update takes a security-fix step (2026-10-23, D39 = B, `09` §3 decision 255) -**What:** the day brief's Part 1. Design read: `07-backup-architecture.md` §6.3, §8; `09-update-architecture.md` §3 -decision 250 (a release needs no golden; the floor carries it, MinAgent declared). +**What:** `legCandidate` takes `proven` OR `security_fix`; every other guard unchanged and in the same order; the leg +names a taken security step in its log, its summary step and its summary line. Design read: `09-update-architecture.md` +§3 decisions 13–15, 52, 255. -## Release +**Tests** (`internal/stacks/unattended_security_test.go`, six): red-proved by four mutations — the OLD verdict line +(taken + needs_person red), a security_fix returning before the guards (needs_person, held, stopped red), any verdict but +unrecorded taken (unknown-verdict red), every step logged as a security fix (proven red). Held and stopped run BEFORE the +verdict line, so the old line cannot turn them red; that is why their red-proof is a different mutation. +`go build`, `go vet`, `go test ./...` green; `controller_gates.py --fast` OK. -- Main at `caaa86e` (the CHANGELOG header `## v0.340.0 … MinAgent: 0.158.1`; the four night sections now under it). - CI 2245 success. The operator answered „release main" for the text-only commits after the night-tested ones. -- Image `gitea.dooplex.hu/admin/felhom-controller:0.340.0`, digest - `sha256:a8d1c6458da4ea9d667f928498656a291771e0b3756b20f2fcc8c1cf9d516621`. -- 9202 smoke first (healthy, 0 errors, `/login` 200, start-up „no leftover"). -- Per-customer floors for Tester 1, demo-hp, demo-felhom (303 `floor_set` each) → all three on 0.340.0 through the - normal update path: Tester 1 10 containers, demo-hp 24, N100 9; 0 unhealthy, 0 errors, 0 copy containers, each with the - start-up „no leftover" line. `https_only_check.py` rc 0, 28 OK, the negative control convicted. -- „Fájlok ↗" on Tester 1: `/__files/open` → 302 to `files.enkicsifelhom.hu/_felhom/sso`. Tester 1's manual second-drive - run: 3 apps, 0 copy containers left. demo-hp and demo-felhom: checked only without a session (302 /login) — no - dashboard password is held for them; their first night copy is tonight. -- Global floor 0.340.0, MinAgent 0.158.1: 303 `floor_set` at 06:41:04 UTC; the per-customer floors cleared; read back - from the hub DB with its WAL. - -## What it carries - -The stray-copy fix and the night's review fixes (R-1214, R-49 D21 = B, R-1187 c, R-1189 d, C3–C8, N2–N5, N9, S1, S2) — -see `CHANGELOG.md` v0.340.0. R-49 closed; R-1214 stays open for one dated check (2026-10-24: Tester 1's first night copy -in the capped container, memory „full" pressure under 20 %); R-1187 and R-1189 keep their unfixed parts. - -Evidence: felhom.eu `documentation/audits/day-2026-10-23/01`–`06`. Full day: felhom.eu `REPORT-day-2026-10-23.md`. +**Live:** see the felhom.eu evidence `documentation/audits/security-lane-2026-10-23/` (P2 old controller, P3 the rc). diff --git a/controller/README.md b/controller/README.md index 3bfcbbf..6fe334b 100644 --- a/controller/README.md +++ b/controller/README.md @@ -873,6 +873,14 @@ sends no mail; the app page says „Automatikus frissítés %s-kor — sikeres." One summary line per night (`[update-leg] update leg (after-offsite): done=… undone=… held=… failed=… skipped=… [skipped: app=reason, …]`) in the log and in the hub report's `update_leg`. After `done`/`undone` the app's steps-left and badge are fresh at once (R-678). `stacks.update_window` is removed (it was never read). +**A security-fix step (v0.341.0, `09` §3 decision 255, D39 = B).** The leg also takes a ladder entry with +`"verdict": "security_fix"` — an urgent upstream fix proven on the box (scratch 9202, the guarded Update) whose bench run +is optional, with its public notice (`security`: CVE/GHSA ids, the advisory, the first fixed version). Every other rule +above holds for it unchanged and in the same order (held, stopped by the household, `needs_person`, the failed step, +`files_may_change`, W+5h). The leg logs `[update-leg] : security fix taken ( → )`, the hub +report's step carries `security: [ids]`, and the summary line ends `[security fix : ]`. A +controller before 0.341.0 skips such a step at night (`not_proven`); a person's press takes it on every controller +with ladders (the press never read the verdict). **Leftovers (v0.272.0).** The backup page says in plain words when a whole-box backup was skipped because it cannot fit (the agent's numbers; R-685). A restore that lifts an update hold removes that hold's undo copies diff --git a/controller/internal/stacks/ladder.go b/controller/internal/stacks/ladder.go index 84dee13..8ea68d8 100644 --- a/controller/internal/stacks/ladder.go +++ b/controller/internal/stacks/ladder.go @@ -52,6 +52,22 @@ type LadderEntry struct { // PostgreSQL major (pgconvert.go). omitempty: an entry without it prints exactly as before, so R-680's // failed-step records (tied to LadderPrint) are not reset by this field's arrival. EngineConversion *EngineConversion `yaml:"engine_conversion" json:"engine_conversion,omitempty"` + // Security (v0.341.0, `09` §3 decision 255, D39 = B) is a `security_fix` step's public notice: the CVE/GHSA ids, + // the advisory's address and the first fixed upstream version. The night leg names the ids when it takes the step. + // omitempty, like EngineConversion: an entry without it prints (LadderPrint) exactly as before. + Security *LadderSecurity `yaml:"security" json:"security,omitempty"` +} + +// LadderVerdictSecurityFix is the second verdict the night leg takes (v0.341.0, decision 255): an urgent upstream fix +// proven on the box (scratch 9202, the guarded Update) whose bench run is optional. A person's press has never read the +// verdict, so on every controller with ladders it was already pressable by hand; before v0.341.0 the leg skips it. +const LadderVerdictSecurityFix = "security_fix" + +// LadderSecurity is the `security` object of a security_fix ladder entry (the catalog's `scripts/ladder.py` checks it). +type LadderSecurity struct { + IDs []string `yaml:"ids" json:"ids"` + Advisory string `yaml:"advisory" json:"advisory"` + FixedIn string `yaml:"fixed_in" json:"fixed_in"` } // LadderMarks is the `marks` object of a ladder entry. NeedsPerson is JSON null (nil) or the tester's diff --git a/controller/internal/stacks/unattended.go b/controller/internal/stacks/unattended.go index abbdc7d..1f1603f 100644 --- a/controller/internal/stacks/unattended.go +++ b/controller/internal/stacks/unattended.go @@ -19,7 +19,8 @@ import ( // It presses the SAME public guarded Update a person presses (StartGuardedUpdate) — no second update // path — one app at a time, ONE tested step per app per night, and never: // - an app whose installed version has no ladder entry (older than the ladder, or no test record); -// - a step whose verdict is not `proven`, or that carries the `needs_person` mark; +// - a step whose verdict is not `proven` or `security_fix` (v0.341.0, decision 255 — an urgent fix proven on the +// box, bench optional; every other guard below holds for it unchanged), or that carries the `needs_person` mark; // - a `files_may_change` step unless a fresh WHOLE copy of the app exists on the box (decision 25/26); // - the step that was undone or held last time, while the catalog's ladder is unchanged (R-680); // - a held app, an app that is current, ahead, or unorderable. @@ -92,6 +93,8 @@ type LegStep struct { From map[string]string `json:"from,omitempty"` To map[string]string `json:"to,omitempty"` Seconds float64 `json:"seconds,omitempty"` + // Security names the public notice of a security_fix step the leg took (v0.341.0, decision 255): its CVE/GHSA ids. + Security []string `json:"security,omitempty"` } // UpdateLegSummary is one night's leg — the operator's summary line and the hub report's `update_leg`. @@ -123,9 +126,15 @@ func (s *UpdateLegSummary) Line() string { if s.Stopped != "" { stop = " stopped=" + s.Stopped } - return fmt.Sprintf("update leg (%s): done=%d undone=%d held=%d failed=%d skipped=%d%s in %s [skipped: %s]", + sec := "" + for _, st := range s.Steps { + if len(st.Security) > 0 && st.Outcome != LegOutcomeSkipped { + sec += fmt.Sprintf(" [security fix %s: %s %s]", st.App, strings.Join(st.Security, ","), st.Outcome) + } + } + return fmt.Sprintf("update leg (%s): done=%d undone=%d held=%d failed=%d skipped=%d%s in %s [skipped: %s]%s", s.Trigger, s.Done, s.Undone, s.Held, s.Failed, s.Skipped, stop, - s.EndedAt.Sub(s.StartedAt).Round(time.Second), strings.Join(skips, ", ")) + s.EndedAt.Sub(s.StartedAt).Round(time.Second), strings.Join(skips, ", "), sec) } func (s *UpdateLegSummary) add(st LegStep) { @@ -334,10 +343,17 @@ func (m *Manager) runUpdateLeg(ctx context.Context, trigger string, manual bool) continue } m.logger.Printf("[INFO] [update-leg] %s: step pressed %s → %s", name, summarisePin(entry.From), summarisePin(entry.To)) + var secIDs []string + if entry.Verdict == LadderVerdictSecurityFix { + if entry.Security != nil { + secIDs = entry.Security.IDs + } + m.logger.Printf("[INFO] [update-leg] %s: security fix %s taken (%s → %s)", name, strings.Join(secIDs, ","), summarisePin(entry.From), summarisePin(entry.To)) + } m.setLegFlags(true, true) outcome := m.legWait(ctx, o, name) m.setLegFlags(true, false) - step := LegStep{App: name, Outcome: outcome, From: entry.From, To: entry.To, Seconds: o.Now().Sub(t0).Round(100 * time.Millisecond).Seconds()} + step := LegStep{App: name, Outcome: outcome, From: entry.From, To: entry.To, Seconds: o.Now().Sub(t0).Round(100 * time.Millisecond).Seconds(), Security: secIDs} sum.add(step) m.recordAutoUpdate(name, &AutoUpdateRecord{At: t0.UTC().Format(time.RFC3339), Outcome: outcome, From: entry.From, To: entry.To}) m.logger.Printf("[INFO] [update-leg] %s: step ended %s after %.1f s", name, outcome, step.Seconds) @@ -438,7 +454,7 @@ func (m *Manager) legCandidate(ctx context.Context, name string, o *UpdateLegOpt return LadderEntry{}, LegSkipOlderThanLadder } e := ladder[idx] - if e.Verdict != "proven" { + if e.Verdict != "proven" && e.Verdict != LadderVerdictSecurityFix { return e, LegSkipNotProven } if e.Marks.NeedsPerson != nil && strings.TrimSpace(*e.Marks.NeedsPerson) != "" { diff --git a/controller/internal/stacks/unattended_security_test.go b/controller/internal/stacks/unattended_security_test.go new file mode 100644 index 0000000..44f3d62 --- /dev/null +++ b/controller/internal/stacks/unattended_security_test.go @@ -0,0 +1,108 @@ +package stacks + +import ( + "strings" + "testing" +) + +// v0.341.0 — D39 = B (`09` §3 decision 255): the night leg takes a `security_fix` step like a proven one, and every +// other guard holds for it unchanged and in the same order. Same real leg over the real guarded update as +// unattended_test.go (legManager: nextcloud pinned at A, the catalog at C, ladder A→B→C). +// +// COMPANION RED-PROOF (REPORT.md): against the OLD verdict line (`e.Verdict != "proven"`) the first two tests fail +// (the step is skipped as not_proven). The held and stopped-by-household guards run BEFORE the verdict line, so the old +// line cannot turn them red; their red-proof is the mutation "a security_fix step returns before the guards" (both +// fail). The unknown-verdict test's red-proof is the mutation "any verdict but unrecorded is taken" (it fails). + +const secMarks = `{"files_may_change": false, "needs_person": null, "memory_tight": false}` + +// securityLine is a security_fix ladder entry A→B with its public notice, optionally with other marks. +func securityLine(from, to, marks string) string { + return ` - {"from": {"web": "` + from + `"}, "to": {"web": "` + to + `"}, "digest": {"web": "sha256:` + + strings.Repeat("a", 64) + `"}, "verdict": "security_fix", "marks": ` + marks + + `, "security": {"ids": ["CVE-2026-96652", "CVE-2026-96654"], "advisory": "https://www.cisa.gov/kev", "fixed_in": "1.43.3.10861"}, "seed": "none"}` + "\n" +} + +func TestD39_SecurityFixStepIsTakenAndNamed(t *testing.T) { + m, dir, _, ups, logBuf := legManager(t) + writeLadder(t, m, securityLine(ladderA, ladderB, secMarks), ladderLine(ladderB, ladderC)) + legOpts(m, nil) + s := mustLeg(t, m) + if len(*ups) != 1 || pinOf(t, dir) != ladderB { + t.Fatalf("the security_fix step was not taken: ups=%v pin=%s summary=%+v", *ups, pinOf(t, dir), s) + } + st := legStepFor(s, "nextcloud") + if st.Outcome != LegOutcomeDone || strings.Join(st.Security, ",") != "CVE-2026-96652,CVE-2026-96654" { + t.Fatalf("the summary step must be done and name the ids, got %+v", st) + } + want := "[update-leg] nextcloud: security fix CVE-2026-96652,CVE-2026-96654 taken (" + summarisePin(map[string]string{"web": ladderA}) + " → " + summarisePin(map[string]string{"web": ladderB}) + ")" + if !strings.Contains(logBuf.String(), want) { + t.Fatalf("the log must carry %q; log:\n%s", want, logBuf.String()) + } + if !strings.Contains(s.Line(), "[security fix nextcloud: CVE-2026-96652,CVE-2026-96654 done]") { + t.Fatalf("the operator's summary line must name the security fix: %s", s.Line()) + } +} + +func TestD39_SecurityFixWithNeedsPersonIsSkipped(t *testing.T) { + m, dir, _, ups, _ := legManager(t) + writeLadder(t, m, securityLine(ladderA, ladderB, `{"files_may_change": false, "needs_person": "the admin must sign in again", "memory_tight": false}`), + ladderLine(ladderB, ladderC)) + legOpts(m, nil) + s := mustLeg(t, m) + if len(*ups) != 0 || pinOf(t, dir) != ladderA || legStepFor(s, "nextcloud").Reason != LegSkipNeedsPerson { + t.Fatalf("security_fix + needs_person: ups=%v pin=%s step=%+v, want skipped %q", *ups, pinOf(t, dir), legStepFor(s, "nextcloud"), LegSkipNeedsPerson) + } +} + +func TestD39_SecurityFixOnAHeldAppIsSkipped(t *testing.T) { + m, _, g, ups, _ := legManager(t) + g.held, g.holdWhy = true, "HELD" + writeLadder(t, m, securityLine(ladderA, ladderB, secMarks), ladderLine(ladderB, ladderC)) + legOpts(m, nil) + s := mustLeg(t, m) + if len(*ups) != 0 || legStepFor(s, "nextcloud").Reason != LegSkipHeld { + t.Fatalf("security_fix on a held app: ups=%v step=%+v, want skipped %q", *ups, legStepFor(s, "nextcloud"), LegSkipHeld) + } +} + +func TestD39_SecurityFixOnAnAppTheHouseholdStoppedIsSkipped(t *testing.T) { + m, _, _, ups, _ := legManager(t) + writeLadder(t, m, securityLine(ladderA, ladderB, secMarks), ladderLine(ladderB, ladderC)) + legOpts(m, nil) + if err := m.ScanStacks(); err != nil { + t.Fatal(err) + } + if err := m.SetDesiredState("nextcloud", DesiredStateStopped); err != nil { + t.Fatal(err) + } + s := mustLeg(t, m) + if len(*ups) != 0 || legStepFor(s, "nextcloud").Reason != LegSkipStoppedByHousehold { + t.Fatalf("security_fix on a stopped app: ups=%v step=%+v, want skipped %q", *ups, legStepFor(s, "nextcloud"), LegSkipStoppedByHousehold) + } +} + +func TestD39_AnUnknownVerdictIsStillNotProven(t *testing.T) { + for _, v := range []string{"security-fix", "failed", "inconclusive", ""} { + m, _, _, ups, _ := legManager(t) + writeLadder(t, m, ladderLineMarks(ladderA, ladderB, v, secMarks), ladderLine(ladderB, ladderC)) + legOpts(m, nil) + s := mustLeg(t, m) + if len(*ups) != 0 || legStepFor(s, "nextcloud").Reason != LegSkipNotProven { + t.Fatalf("verdict %q: ups=%v step=%+v, want skipped %q", v, *ups, legStepFor(s, "nextcloud"), LegSkipNotProven) + } + } +} + +func TestD39_ProvenIsUnchangedAndCarriesNoSecurityNames(t *testing.T) { + m, dir, _, _, logBuf := legManager(t) + legOpts(m, nil) + s := mustLeg(t, m) + st := legStepFor(s, "nextcloud") + if pinOf(t, dir) != ladderB || st.Outcome != LegOutcomeDone || st.Security != nil { + t.Fatalf("a proven step: pin=%s step=%+v, want B done with no security names", pinOf(t, dir), st) + } + if strings.Contains(logBuf.String(), "security fix") || strings.Contains(s.Line(), "security fix") { + t.Fatalf("a proven step must not be logged as a security fix") + } +}