R-861: narrow the agent's root grants — exact sudo patterns, felhom-priv-apply content checker, fixed hook/parent files in the bundle, signed self-update verified as root, escrow root reads pinned
gates / gates (push) Successful in 20s

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-10-05 12:00:43 +02:00
parent 61345790ed
commit 6ab1e7c56c
39 changed files with 1657 additions and 400 deletions
+14
View File
@@ -22,6 +22,7 @@ import (
"os/exec"
"os/signal"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
@@ -2697,6 +2698,9 @@ func (e *escrowCeremonyErr) Error() string { return e.err.Error() }
// restic password auto-attach), Create (R + self-verified blob), wipe the staged secret, upload
// when asked. It PRINTS NOTHING — the output-mode shells own every byte of stdout/stderr. R is
// returned for the caller to surface exactly once; escrow.Create never logs it and neither do we.
// pbsStorageIDRe is a PVE storage id (letters, digits, '-', '_', '.'; starts with a letter) — never a path (R-861).
var pbsStorageIDRe = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_.-]{0,63}$`)
func escrowCeremony(ctx context.Context, cfg config.Config, logger *slog.Logger, opts escrowCeremonyOpts) (escrowCeremonyOutcome, *escrowCeremonyErr) {
var out escrowCeremonyOutcome
storage := opts.storage
@@ -2706,6 +2710,16 @@ func escrowCeremony(ctx context.Context, cfg config.Config, logger *slog.Logger,
if storage == "" {
return out, &escrowCeremonyErr{kind: "usage", err: fmt.Errorf("selftest=escrow-create requires -storage <pbs-storage-id> (or escrow.pbs_storage_id)")}
}
// R-861 (v0.146.0): this runs as ROOT through FELHOM_ESCROW, but agent.json is owned by the agent user. So the
// paths a root run reads never come from it: the PVE secret dir and the WireGuard state dir are the fixed defaults,
// and the storage id is a plain PVE id (no slash, no dot-dot) — a crafted id or dir would read another root file.
if os.Geteuid() == 0 {
cfg.Backup.PBSSecretDir = ""
cfg.WGTunnel.StateDir = ""
}
if !pbsStorageIDRe.MatchString(storage) {
return out, &escrowCeremonyErr{kind: "usage", err: fmt.Errorf("selftest=escrow-create: storage id %q is not a plain PVE storage id", storage)}
}
out.Storage = storage
keyPath := cfg.Backup.PBSEncKeyPath(storage)
if _, err := os.Stat(keyPath); err != nil {
@@ -0,0 +1,31 @@
package main
import (
"context"
"io"
"log/slog"
"testing"
"gitea.dooplex.hu/admin/felhom-agent/internal/config"
)
// R-861 (agent v0.146.0): the root escrow ceremony builds a file path from the storage id; an id that is a path is
// refused before anything is read. RED-PROOF: drop the pbsStorageIDRe check → the "../" ids reach the key stat and
// come back as a "setup" error instead of "usage".
func TestEscrowCeremony_StorageIDIsNeverAPath(t *testing.T) {
lg := slog.New(slog.NewTextHandler(io.Discard, nil))
for _, id := range []string{"../../../etc/shadow", "a/b", "/etc/pve/priv/x", ".hidden", ""} {
cfg := config.Default()
cfg.Escrow.PBSStorageID = "" // the flag decides here
_, e := escrowCeremony(context.Background(), cfg, lg, escrowCeremonyOpts{storage: id})
if e == nil || e.kind != "usage" {
t.Errorf("storage id %q was not refused as usage (got %+v)", id, e)
}
}
cfg := config.Default()
cfg.Backup.PBSSecretDir = t.TempDir()
_, e := escrowCeremony(context.Background(), cfg, lg, escrowCeremonyOpts{storage: "felhom-pbs"})
if e == nil || e.kind != "setup" {
t.Fatalf("control: a plain id must pass the check and fail later on the missing key (setup), got %+v", e)
}
}