8033a522cd
- reconcile: ClassAgentUpdate op class; always Destructive (no provenance
blesses replacing the root-adjacent binary). classify test + companion
(TestClassify_AgentUpdateAlwaysDestructive).
- opsign: `-op agent_update` with -agent-version + -sha256 (isHex64-validated);
params {version,sha256}. isHex64 test (Group D).
- config: SelfUpdateConfig{URLTemplate,Username,Token,StateDir,DwellSeconds}
+ WithDefaults + Token redaction.
- internal/selfupdate: Executor (download → verify vs the SIGNED sha → sudo -n
wrapper `apply`; sha is the only integrity root — mismatch refuses + removes,
agent untouched); Manager (startup dwell → `commit`; version-mismatch → no
commit + loud WARN + marker left for report visibility; shutdown-before-dwell
leaves pending). WrapperRunner seam → tests never shell out.
- hub report: additive selfupdate_pending(+version) via SetSelfUpdateReporter
seam; both omitempty (Wireguard precedent) so the cross-repo golden contract
stays byte-stable — no hub change.
- capability manifest: 3 non-critical FELHOM_SELFUPDATE probes.
- main.go: updateExec appended to the executor chain; commit-manager wired to
the report seam + MaybeCommit goroutine after core init.
Tests: Group A (executor happy/sha-mismatch+companion/bad-params/wrapper-fail),
B (agent_update rides the real gate: pinned-key executes, non-pinned +
retarget rejected), C (commit/version-mismatch/no-pending/shutdown), D (opsign).
C2 companion red-proof verified (neutered Go verify → bad binary reaches apply
→ test fails), reverted. Full go test ./... green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
81 lines
3.1 KiB
Go
81 lines
3.1 KiB
Go
package reconcile
|
|
|
|
import (
|
|
"testing"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-agent/internal/authz"
|
|
)
|
|
|
|
func TestClassify_BenignClasses(t *testing.T) {
|
|
for _, c := range []OpClass{ClassStart, ClassStop, ClassSetConfig, ClassCreate, ClassRestart} {
|
|
if got := Classify(c, Provenance{}); got != Benign {
|
|
t.Errorf("Classify(%s) = %s, want benign", c, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestClassify_DestructiveClassesNeedSignature(t *testing.T) {
|
|
for _, c := range []OpClass{ClassGuestDestroy, ClassStorageWipe, ClassRestoreOverwrite, ClassDecommission, ClassKeyRotation, ClassAgentUpdate} {
|
|
if got := Classify(c, Provenance{}); got != Destructive {
|
|
t.Errorf("Classify(%s) = %s, want destructive", c, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestClassify_InternalProvenanceMakesDestroyBenign(t *testing.T) {
|
|
// Same-transaction create → compensating rollback is benign (§10).
|
|
if got := Classify(ClassGuestDestroy, Provenance{SameTxnCreated: true}); got != Benign {
|
|
t.Errorf("same-txn destroy = %s, want benign", got)
|
|
}
|
|
// Agent-tagged scratch teardown is benign (§8).
|
|
if got := Classify(ClassGuestDestroy, Provenance{AgentTaggedScratch: true}); got != Benign {
|
|
t.Errorf("scratch destroy = %s, want benign", got)
|
|
}
|
|
}
|
|
|
|
func TestClassify_KeyRotationAlwaysDestructive(t *testing.T) {
|
|
// Even with internal provenance, key-rotation stays signed (role-scoping decides
|
|
// which key) — provenance flags don't apply to it.
|
|
if got := Classify(ClassKeyRotation, Provenance{SameTxnCreated: true, AgentTaggedScratch: true}); got != Destructive {
|
|
t.Errorf("key_rotation = %s, want destructive", got)
|
|
}
|
|
}
|
|
|
|
// TASK D1: agent_update (replacing the root-adjacent binary) is ALWAYS destructive — no
|
|
// agent-internal provenance can bless it unsigned (a compromised process must not self-update).
|
|
// This is what gates the signed-jobs binary swap; if it flipped to Benign the runner would execute
|
|
// an unsigned agent_update (the companion the signedjobs ride-along tests rely on).
|
|
func TestClassify_AgentUpdateAlwaysDestructive(t *testing.T) {
|
|
if got := Classify(ClassAgentUpdate, Provenance{SameTxnCreated: true, AgentTaggedScratch: true}); got != Destructive {
|
|
t.Errorf("agent_update = %s, want destructive even with internal provenance", got)
|
|
}
|
|
}
|
|
|
|
func TestClassify_UnknownClassFailsSafe(t *testing.T) {
|
|
if got := Classify(OpClass("totally_unknown_op"), Provenance{}); got != Destructive {
|
|
t.Errorf("unknown class = %s, want destructive (fail-safe)", got)
|
|
}
|
|
}
|
|
|
|
func TestRoleAuthorizes(t *testing.T) {
|
|
op := authz.RoleOperational
|
|
rec := authz.RoleRecovery
|
|
cases := []struct {
|
|
role authz.KeyRole
|
|
class OpClass
|
|
want bool
|
|
}{
|
|
{op, ClassGuestDestroy, true}, // operational does ordinary destructive
|
|
{op, ClassDecommission, true}, //
|
|
{op, ClassKeyRotation, true}, // operational does planned rotation
|
|
{rec, ClassGuestDestroy, false}, // recovery may NOT do ordinary destructive
|
|
{rec, ClassStorageWipe, false}, //
|
|
{rec, ClassKeyRotation, true}, // recovery authorizes ONLY rotation
|
|
}
|
|
for _, c := range cases {
|
|
if got := roleAuthorizes(c.role, c.class); got != c.want {
|
|
t.Errorf("roleAuthorizes(%s, %s) = %v, want %v", c.role, c.class, got, c.want)
|
|
}
|
|
}
|
|
}
|