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
+63 -8
View File
@@ -3,6 +3,7 @@ package selfupdate
import (
"context"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
@@ -11,6 +12,7 @@ import (
"net/http"
"os"
"path/filepath"
"strings"
"time"
"gitea.dooplex.hu/admin/felhom-agent/internal/signedjobs"
@@ -47,6 +49,7 @@ type Executor struct {
username string
token string
stateDir string
planDir string // felhom-os-apply's plan dir; "" = defaultPlanDir (tests set a temp dir)
runner WrapperRunner
httpClient *http.Client
logger *slog.Logger
@@ -126,18 +129,70 @@ func (e *Executor) Execute(ctx context.Context, op string, params json.RawMessag
return fmt.Errorf("agent_update: chmod staged: %w", err)
}
// Hand off to the root wrapper. It re-verifies the sha, flips A/B, writes the pending marker,
// and schedules the detached restart. After this the new binary starts; the commit is the
// Manager's job once it has dwelled cleanly.
e.logger.Warn("agent_update: handing staged binary to the guarded wrapper", "staged", staged, "version", p.Version)
stdout, stderr, err := e.runner.Run(ctx, wrapperPath, "apply", staged, p.SHA256)
if err != nil {
return fmt.Errorf("agent_update: wrapper apply failed: %w (stderr: %s)", err, string(stderr))
// R-861 (v0.146.0): hand the SIGNED job to the root wrapper felhom-os-apply (mode agent_update). It verifies the
// operator's signature itself (root-owned signers file, this host, the window, the nonce), re-hashes the staged
// file against the SIGNED sha, and only then runs the A/B flip (felhom-selfupdate-guarded apply, no longer in the
// agent's sudoers). Until v0.146.0 the agent passed the sha to the flip itself, so a compromised agent could
// install any binary — and the binary is what the escrow ceremony and the guest hook run as root.
so, ok := signedjobs.SignedOpFrom(ctx)
if !ok {
return fmt.Errorf("agent_update: no signed envelope in the context — the root wrapper could not verify it")
}
e.logger.Warn("agent_update: apply handed off; restart scheduled", "version", p.Version, "wrapper", trim(stdout))
planDir := e.planDir
if planDir == "" {
planDir = defaultPlanDir
}
if err := os.MkdirAll(planDir, 0o700); err != nil {
return fmt.Errorf("agent_update: plan dir: %w", err)
}
plan, _ := json.Marshal(map[string]any{"release_id": "agent-" + p.Version, "layer": "host", "mode": "agent_update",
"staged": staged, "signed": map[string]string{"blob_b64": base64.StdEncoding.EncodeToString(so.Blob), "sig": string(so.Sig)}})
planPath := filepath.Join(planDir, "plan-agentupdate-"+p.Version+".json")
if err := os.WriteFile(planPath, plan, 0o600); err != nil {
return fmt.Errorf("agent_update: write plan: %w", err)
}
defer os.Remove(planPath)
e.logger.Warn("agent_update: handing the signed job to the root wrapper (felhom-os-apply agent_update)", "staged", staged, "version", p.Version)
stdout, stderr, err := e.runner.Run(ctx, osApplyPath, "--plan", planPath)
rep := parseOSApplyReport(stdout)
var r struct {
Refused json.RawMessage `json:"refused"`
Failed json.RawMessage `json:"failed"`
AgentUpdate json.RawMessage `json:"agent_update"`
}
jerr := json.Unmarshal([]byte(rep), &r)
refused := len(r.Refused) > 0 && string(r.Refused) != "null"
if err != nil || jerr != nil || refused || len(r.Failed) > 0 || len(r.AgentUpdate) == 0 {
return fmt.Errorf("agent_update: the root wrapper did not apply it: %v (report: %s; stderr: %s)", err, trimStr(rep), trim(stderr))
}
e.logger.Warn("agent_update: signed update verified as root and handed off; restart scheduled", "version", p.Version, "report", trimStr(rep))
return nil
}
// osApplyPath is the root wrapper that verifies the signed agent_update (R-861). Fixed, never config-overridable.
const osApplyPath = "/usr/local/sbin/felhom-os-apply"
// defaultPlanDir is felhom-os-apply's ONLY plan directory (PLAN_DIR there; osupdate.DefaultPlanDir here).
const defaultPlanDir = "/var/lib/felhom-agent/os"
// parseOSApplyReport returns the JSON after the wrapper's last "OSAPPLY-REPORT " line ("" when there is none).
func parseOSApplyReport(stdout []byte) string {
rep := ""
for _, line := range strings.Split(string(stdout), "\n") {
if strings.HasPrefix(line, "OSAPPLY-REPORT ") {
rep = strings.TrimPrefix(line, "OSAPPLY-REPORT ")
}
}
return rep
}
func trimStr(s string) string {
if len(s) > 400 {
return s[:400] + "…"
}
return s
}
// download streams url → dest (0644, fsync'd) and returns the lowercase-hex sha256 of the bytes.
func (e *Executor) download(ctx context.Context, url, dest string) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
+69 -11
View File
@@ -15,34 +15,56 @@ import (
"sync"
"testing"
"gitea.dooplex.hu/admin/felhom-agent/internal/reconcile"
"gitea.dooplex.hu/admin/felhom-agent/internal/signedjobs"
)
// fakeWrapper records the verbs the executor/manager shell out, and returns a configurable error.
// fakeWrapper records the verbs the executor/manager shell out, and returns a configurable error. For the os-apply
// call it snapshots the plan file at call time (the executor removes it afterwards) and answers with report.
type fakeWrapper struct {
mu sync.Mutex
calls [][]string
err error
mu sync.Mutex
calls [][]string
err error
plans []map[string]any
report string // the OSAPPLY-REPORT JSON; "" = a successful agent_update
}
func (f *fakeWrapper) Run(_ context.Context, name string, args ...string) ([]byte, []byte, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.calls = append(f.calls, append([]string{name}, args...))
if name == osApplyPath && len(args) == 2 && args[0] == "--plan" {
var p map[string]any
b, _ := os.ReadFile(args[1])
_ = json.Unmarshal(b, &p)
f.plans = append(f.plans, p)
rep := f.report
if rep == "" {
rep = `{"agent_update": {"version": "x", "wrapper_rc": 0}, "mode": "agent_update", "refused": null}`
}
return []byte("OSAPPLY-REPORT " + rep + "\n"), nil, f.err
}
return []byte("ok"), nil, f.err
}
// applyCalls are the hand-offs to the root wrapper (felhom-os-apply --plan …). Since v0.146.0 the agent never calls
// `felhom-selfupdate-guarded apply` itself.
func (f *fakeWrapper) applyCalls() [][]string {
f.mu.Lock()
defer f.mu.Unlock()
var out [][]string
for _, c := range f.calls {
if len(c) >= 2 && c[1] == "apply" {
if c[0] == osApplyPath || (len(c) >= 2 && c[1] == "apply") {
out = append(out, c)
}
}
return out
}
func signedCtx() context.Context {
return signedjobs.WithSignedOp(context.Background(), &reconcile.SignedOp{Blob: []byte(`{"op":"agent_update"}`), Sig: []byte("SIG")})
}
func sha256Of(b []byte) string {
h := sha256.Sum256(b)
return hex.EncodeToString(h[:])
@@ -65,6 +87,7 @@ func newExec(t *testing.T, srv *httptest.Server, wrap WrapperRunner) (*Executor,
Runner: wrap,
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
})
e.planDir = t.TempDir()
return e, stateDir
}
@@ -84,7 +107,7 @@ func TestExecutor_HappyPath(t *testing.T) {
e, stateDir := newExec(t, srv, wrap)
sha := sha256Of(body)
if err := e.Execute(context.Background(), "agent_update", updateParamsJSON(t, "0.70.1", sha)); err != nil {
if err := e.Execute(signedCtx(), "agent_update", updateParamsJSON(t, "0.70.1", sha)); err != nil {
t.Fatalf("execute: %v", err)
}
staged := filepath.Join(stateDir, "selfupdate", "felhom-agent-0.70.1")
@@ -93,11 +116,46 @@ func TestExecutor_HappyPath(t *testing.T) {
t.Fatalf("staged binary missing/mismatch: %v", err)
}
calls := wrap.applyCalls()
if len(calls) != 1 {
t.Fatalf("apply invoked %d times, want 1 (%v)", len(calls), wrap.calls)
if len(calls) != 1 || calls[0][0] != osApplyPath || calls[0][1] != "--plan" {
t.Fatalf("want exactly one hand-off to felhom-os-apply --plan, got %v", wrap.calls)
}
if calls[0][2] != staged || calls[0][3] != sha {
t.Errorf("apply args = %v, want [.. apply %s %s]", calls[0], staged, sha)
// R-861: the plan carries the SIGNED envelope and the staged path; the wrapper, not the agent, decides.
p := wrap.plans[0]
sg, _ := p["signed"].(map[string]any)
if p["mode"] != "agent_update" || p["layer"] != "host" || p["staged"] != staged || sg["sig"] != "SIG" || sg["blob_b64"] == "" {
t.Errorf("plan = %v, want mode agent_update + the staged path + the signed envelope", p)
}
if _, err := os.Stat(calls[0][2]); !os.IsNotExist(err) {
t.Error("the plan file was left behind")
}
}
// R-861: without the signed envelope nothing reaches the root wrapper.
// RED-PROOF (audits/hub-safety-2026-10-05/partF/red-proof.txt): call `felhom-selfupdate-guarded apply` directly again
// → the happy path's "exactly one hand-off to felhom-os-apply" fails.
func TestExecutor_NoEnvelopeNoHandOff(t *testing.T) {
body := []byte("good bytes")
srv := artifactServer(t, body)
defer srv.Close()
wrap := &fakeWrapper{}
e, _ := newExec(t, srv, wrap)
if err := e.Execute(context.Background(), "agent_update", updateParamsJSON(t, "0.70.1", sha256Of(body))); err == nil {
t.Fatal("an update with no signed envelope was handed on")
}
if len(wrap.applyCalls()) != 0 {
t.Fatalf("the root wrapper was called without an envelope: %v", wrap.calls)
}
}
// A refusal in the wrapper's report is a failure, even when its exit code reads 0.
func TestExecutor_WrapperRefusalSurfaces(t *testing.T) {
body := []byte("good bytes")
srv := artifactServer(t, body)
defer srv.Close()
wrap := &fakeWrapper{report: `{"mode": "agent_update", "refused": {"code": "R3", "reason": "the operator signature does not verify"}}`}
e, _ := newExec(t, srv, wrap)
if err := e.Execute(signedCtx(), "agent_update", updateParamsJSON(t, "0.70.1", sha256Of(body))); err == nil {
t.Fatal("a refused signed update read as applied")
}
}
@@ -158,7 +216,7 @@ func TestExecutor_WrapperFailureSurfaces(t *testing.T) {
defer srv.Close()
wrap := &fakeWrapper{err: errors.New("wrapper refused: sha mismatch")}
e, _ := newExec(t, srv, wrap)
if err := e.Execute(context.Background(), "agent_update", updateParamsJSON(t, "0.70.1", sha256Of(body))); err == nil {
if err := e.Execute(signedCtx(), "agent_update", updateParamsJSON(t, "0.70.1", sha256Of(body))); err == nil {
t.Fatal("wrapper failure must surface")
}
}