Watch
1
0
Fork
You've already forked RedFlag
0

standalone: carry pacman through local authority

Give a standalone Agent a durable identity and one signed pacman envelope path through the helper.

Every archive and detached signature crosses root custody before mutation. Fleet credentials still refuse the local minter.
This commit is contained in:
Fimeg 2026-09-01 14:12:39 -04:00
commit 7472d21c42
21 changed files with 2672 additions and 397 deletions

View file

@ -18,6 +18,7 @@ type CLI struct {
Scan bool
Status bool
LocalStatus bool
InitStandalone bool
ListUpdates bool
Version bool
ServerURL string
@ -48,6 +49,7 @@ func ParseFlags() *CLI {
flag.BoolVar(&cli.Scan, "scan", false, "Scan for updates and display locally")
flag.BoolVar(&cli.Status, "status", false, "Show agent status")
flag.BoolVar(&cli.LocalStatus, "local-status", false, "Show live local agent status over local IPC")
flag.BoolVar(&cli.InitStandalone, "init-standalone", false, "Create or print this host's standalone Agent identity")
flag.BoolVar(&cli.ListUpdates, "list-updates", false, "List detailed update information")
flag.BoolVar(&cli.Version, "version", false, "Show version information")
flag.StringVar(&cli.ServerURL, "server", "", "Server URL")

View file

@ -78,8 +78,22 @@ func main() {
}
}
if cli.InitStandalone {
if err := cfg.InitializeStandalone(); err != nil {
log.Fatal("Standalone initialization failed: ", err)
}
if err := cfg.Save(configPath); err != nil {
log.Fatal("Standalone configuration save failed: ", err)
}
fmt.Println(cfg.AgentID.String())
return
}
// Handle registration command
if cli.Register {
if cfg.IsStandalone() {
log.Fatal("Registration refused: standalone fleet join is not implemented; do not add fleet credentials beside local authority")
}
if err := handleRegistration(cfg, cli.ServerURL); err != nil {
log.Fatal("Registration failed:", err)
}
@ -120,8 +134,8 @@ func main() {
defer unlock()
// Check if registered
if !cfg.IsRegistered() {
log.Fatal("Agent not registered. Run with -register flag first.")
if !cfg.IsRegistered() && !cfg.IsStandalone() {
log.Fatal("Agent has no complete identity. Register with a fleet or run the standalone provisioning script.")
}
// Check if running as Windows service

View file

@ -36,6 +36,7 @@ import (
"github.com/Fimeg/RedFlag/agent/internal/supplychain"
"github.com/Fimeg/RedFlag/agent/internal/system"
"github.com/Fimeg/RedFlag/agent/internal/version"
"github.com/gofrs/uuid/v5"
)
// newCircuitBreaker creates a circuit breaker from config
@ -54,8 +55,12 @@ func RunAgentLoop(cfg *config.Config) error {
defer recovery.Recover("agent_main_loop")
log.Printf("RedFlag Agent v%s starting...", version.Version)
log.Printf("Agent ID: %s Server: %s Interval: %ds",
cfg.AgentID, cfg.ServerURL, cfg.CheckInInterval)
mode := "fleet"
if cfg.IsStandalone() {
mode = "standalone"
}
log.Printf("Agent ID: %s Mode: %s Server: %s Interval: %ds",
cfg.AgentID, mode, cfg.ServerURL, cfg.CheckInInterval)
loopCtx, err := NewLoopContext(cfg, LoopContextOptions{
Ctx: context.Background(),
@ -67,7 +72,9 @@ func RunAgentLoop(cfg *config.Config) error {
// Post-upgrade attestation and healthcheck run after the canonical context
// exists so any warning path can use the operational event channel.
handlers.RunUpgradeAttestation(loopCtx.APIClient, cfg, loopCtx.AckTracker)
if cfg.IsRegistered() {
handlers.RunUpgradeAttestation(loopCtx.APIClient, cfg, loopCtx.AckTracker)
}
if gaps := handlers.RunPostUpgradeHealthcheck(cfg); gaps > 0 {
log.Printf("[INFO] [agent] [healthcheck] gaps=%d — upgrade may be incomplete; re-run install script to reconcile", gaps)
}
@ -309,6 +316,33 @@ type LoopContext struct {
StopCh <-chan struct{} // non-nil causes loop to exit cleanly when closed
}
func runStandaloneLoop(ctx *LoopContext, triggerScan func(string) error) error {
recordLocalAgentStatus(ctx.Cfg, "standalone", false)
log.Printf("[INFO] [agent] [standalone] local_mode_started agent_id=%s", ctx.Cfg.AgentID)
if err := triggerScan("standalone-startup"); err != nil {
ctx.TeeLogger.Warning("agent", "standalone", "scan", "startup_scan_not_started", map[string]interface{}{"error": err.Error()})
}
interval := time.Duration(ctx.Cfg.CheckInInterval) * time.Second
if interval <= 0 {
interval = 5 * time.Minute
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Ctx.Done():
return nil
case <-ctx.StopCh:
return nil
case <-ticker.C:
if err := triggerScan("standalone-interval"); err != nil && !errors.Is(err, localapi.ErrScanInFlight) {
ctx.TeeLogger.Warning("agent", "standalone", "scan", "periodic_scan_not_started", map[string]interface{}{"error": err.Error()})
}
}
}
}
// RunPollingLoop runs the main agent polling loop.
// It is called by RunAgentLoop and may also be called by the Windows service
// with a stop channel. When stopCh is non-nil, the loop selects on it and exits cleanly.
@ -362,6 +396,8 @@ func RunPollingLoop(loopCtx *LoopContext) error {
return nil, fmt.Errorf("%w: %v", localapi.ErrApprovalConflict, err)
case errors.Is(err, supplychain.ErrMintNoAuthority):
return nil, fmt.Errorf("%w: %v", localapi.ErrApprovalUnavailable, err)
case errors.Is(err, handlers.ErrApprovalNoStandaloneIdentity):
return nil, fmt.Errorf("%w: %v", localapi.ErrApprovalUnavailable, err)
default:
return nil, err
}
@ -433,6 +469,10 @@ func RunPollingLoop(loopCtx *LoopContext) error {
}()
}
if ctx.Cfg.IsStandalone() {
return runStandaloneLoop(ctx, triggerScan)
}
consecutiveFailures := 0
lastSystemInfoUpdate := time.Time{}
lastConfigRefresh := time.Time{} // zero → refresh on first successful check-in
@ -892,7 +932,7 @@ func recordLocalAgentStatus(cfg *config.Config, status string, checkedIn bool) {
log.Printf("[WARNING] [agent] [local_state] load_failed error=%v", err)
localCache = &cache.LocalCache{}
}
if cfg != nil && cfg.IsRegistered() {
if cfg != nil && cfg.AgentID != uuid.Nil {
localCache.SetAgentInfo(cfg.AgentID, cfg.ServerURL)
}
localCache.SetAgentStatus(status)

View file

@ -779,6 +779,31 @@ func (c *Config) IsRegistered() bool {
return c.AgentID != uuid.Nil && c.Token != ""
}
// IsStandalone reports whether this config has a stable local identity and no
// fleet credential. A partial fleet enrollment is not standalone: refresh or
// registration material must never silently become local mutation authority.
func (c *Config) IsStandalone() bool {
return c.AgentID != uuid.Nil && c.Token == "" && c.RefreshToken == "" && c.RegistrationToken == ""
}
// InitializeStandalone gives a local-only Agent one durable UUID. It is
// idempotent, but refuses any fleet credential so provisioning cannot convert a
// fleet host into local authority by accident.
func (c *Config) InitializeStandalone() error {
if c.IsRegistered() || c.Token != "" || c.RefreshToken != "" || c.RegistrationToken != "" {
return fmt.Errorf("standalone identity refused: fleet enrollment material is present")
}
if c.AgentID != uuid.Nil {
return nil
}
id, err := uuid.NewV4()
if err != nil {
return fmt.Errorf("generate standalone agent id: %w", err)
}
c.AgentID = id
return nil
}
// OSType represents the operating system type
type OSType string

View file

@ -0,0 +1,49 @@
package config
import (
"testing"
"github.com/gofrs/uuid/v5"
)
func TestInitializeStandaloneIsStable(t *testing.T) {
cfg := &Config{}
if err := cfg.InitializeStandalone(); err != nil {
t.Fatal(err)
}
first := cfg.AgentID
if first == uuid.Nil || !cfg.IsStandalone() || cfg.IsRegistered() {
t.Fatalf("standalone identity not established: id=%s", first)
}
if err := cfg.InitializeStandalone(); err != nil {
t.Fatal(err)
}
if cfg.AgentID != first {
t.Fatalf("standalone identity changed: %s -> %s", first, cfg.AgentID)
}
}
func TestInitializeStandaloneRefusesFleetMaterial(t *testing.T) {
for name, cfg := range map[string]*Config{
"registration token": {RegistrationToken: "register"},
"access token": {Token: "access"},
"refresh token": {RefreshToken: "refresh"},
} {
t.Run(name, func(t *testing.T) {
if err := cfg.InitializeStandalone(); err == nil {
t.Fatal("fleet material became standalone authority")
}
})
}
}
func TestPartialFleetEnrollmentIsNotStandalone(t *testing.T) {
id, err := uuid.NewV4()
if err != nil {
t.Fatal(err)
}
cfg := &Config{AgentID: id, RefreshToken: "refresh"}
if cfg.IsStandalone() {
t.Fatal("partial fleet enrollment reported as standalone")
}
}

View file

@ -2,6 +2,7 @@ package handlers
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
@ -11,6 +12,7 @@ import (
"github.com/Fimeg/RedFlag/agent/internal/config"
"github.com/Fimeg/RedFlag/agent/internal/installer"
"github.com/Fimeg/RedFlag/agent/internal/supplychain"
"github.com/gofrs/uuid/v5"
)
// Standalone local approval (FEAT-003). The full gate pipeline runs locally:
@ -25,6 +27,8 @@ var (
ErrApprovalFleetMode = errors.New("local approval refused: fleet mode — approve via the server")
// ErrApprovalBlocked: a gate refused and no override reason was supplied.
ErrApprovalBlocked = errors.New("approval blocked by supply-chain gate")
// ErrApprovalNoStandaloneIdentity: local authority was not provisioned.
ErrApprovalNoStandaloneIdentity = errors.New("local approval refused: standalone identity is not provisioned")
)
// LocalApproveRequest is RedFlag Desktop's approval submission.
@ -39,18 +43,120 @@ type LocalApproveRequest struct {
// LocalApproveResult carries the gate verdicts plus the execution outcome so
// Desktop can render exactly what was checked and what happened.
type LocalApproveResult struct {
RequestID string `json:"request_id"`
OSVStatus string `json:"osv_status"`
OSVVulnCount int `json:"osv_vuln_count"`
ClosureSize int `json:"closure_size"`
Policy *supplychain.PolicyResult `json:"policy"`
RequestID string `json:"request_id"`
OSVStatus string `json:"osv_status"`
OSVVulnCount int `json:"osv_vuln_count"`
ClosureSize int `json:"closure_size"`
Policy *supplychain.PolicyResult `json:"policy,omitempty"`
Receipt *capability.MutationReceipt `json:"receipt,omitempty"`
}
// gatedLocalApproval limits local approval to the ecosystems whose closures
// the agent can resolve and pin from signed repo metadata. Mirrors the
// capability-gate set, not the full installer set.
func gatedLocalApproval(packageType string) bool {
return packageType == "dnf" || packageType == "apt"
return packageType == "dnf" || packageType == "apt" || packageType == "pacman"
}
type pacmanExecutionLocation struct {
Kind string `json:"kind"`
Value string `json:"value"`
}
type pacmanActionPayload struct {
ArtifactSHA256 string `json:"artifact_sha256"`
ExecutionLocation pacmanExecutionLocation `json:"execution_location"`
Repository string `json:"repository"`
Requested bool `json:"requested"`
SignatureSHA256 string `json:"signature_sha256"`
SignatureLocation pacmanExecutionLocation `json:"signature_location"`
}
func handleLocalPacmanApproval(
ctx context.Context,
cfg *config.Config,
req LocalApproveRequest,
) (*LocalApproveResult, error) {
// OSV has no Arch ecosystem mapping in RedFlag. Preserve that as an
// unsupported gate, which requires the same recorded operator override as
// an outage. Refuse before network and artifact work when intent is absent.
osvStatus := supplychain.OSVStatusUnsupported
if req.OverrideReason == "" {
return nil, fmt.Errorf("%w: osv_status=%s — override requires an explicit reason",
ErrApprovalBlocked, osvStatus)
}
resolution, err := installer.ResolvePacmanClosure(req.PackageName, req.AvailableVersion)
if err != nil {
return nil, fmt.Errorf("resolve pacman closure: %w", err)
}
defer resolution.Cleanup()
resolvedAt := time.Now().UTC()
actions := make([]capability.ResolvedAction, 0, len(resolution.Artifacts))
evidence := make([]capability.Evidence, 0, len(resolution.Artifacts)*2)
for _, artifact := range resolution.Artifacts {
payload, err := json.Marshal(pacmanActionPayload{
ArtifactSHA256: artifact.ArchiveSHA256,
ExecutionLocation: pacmanExecutionLocation{Kind: "cache", Value: artifact.ArchivePath},
Repository: artifact.Repository,
Requested: artifact.Name == req.PackageName && (req.AvailableVersion == "" || artifact.Version == req.AvailableVersion),
SignatureSHA256: artifact.SignatureSHA256,
SignatureLocation: pacmanExecutionLocation{Kind: "cache", Value: artifact.SignaturePath},
})
if err != nil {
return nil, fmt.Errorf("encode pacman action %s: %w", artifact.Name, err)
}
actions = append(actions, capability.ResolvedAction{
Kind: "package", Identity: artifact.Name + "@" + artifact.Version, Payload: string(payload),
})
evidence = append(evidence,
capability.Evidence{Kind: "pacman-package-archive", Digest: artifact.ArchiveSHA256},
capability.Evidence{Kind: "pacman-package-signature", Digest: artifact.SignatureSHA256},
)
}
operationID, err := uuid.NewV4()
if err != nil {
return nil, fmt.Errorf("generate pacman operation id: %w", err)
}
manifest := capability.MutationManifest{
ProtocolVersion: capability.MutationProtocolVersion,
OperationID: operationID.String(),
TargetID: cfg.AgentID.String(),
Backend: "pacman",
Operation: "upgrade",
ResolvedActions: actions,
Evidence: evidence,
}
gateEvidence := supplychain.GateEvidence{
ResolvedAt: resolvedAt.Unix(),
OSVCheckedAt: 0,
OSVStatus: osvStatus,
OSVVulnCount: 0,
AgeGate: "not_applicable",
SoakGate: "not_applicable",
Operator: req.Operator,
OverrideReason: req.OverrideReason,
}
executor := supplychain.NewExecutor("")
envelope, requestID, err := executor.MintEnvelope(ctx, manifest, gateEvidence)
if err != nil {
return nil, err
}
receipt, err := executor.ExecuteEnvelope(ctx, envelope)
if err != nil {
return nil, fmt.Errorf("execute pacman envelope authorization_id=%s: %w",
envelope.Authorization.AuthorizationID, err)
}
log.Printf("[INFO] [agent] [localapprove] approval_completed pkg=%s decision=%s exit=%d authorization_id=%s",
req.PackageName, receipt.Decision, receipt.ExitCode, receipt.AuthorizationID)
return &LocalApproveResult{
RequestID: requestID,
OSVStatus: osvStatus,
OSVVulnCount: 0,
ClosureSize: len(resolution.Artifacts),
Receipt: receipt,
}, nil
}
// HandleLocalApprove runs the standalone approval flow end to end. Synchronous:
@ -60,11 +166,14 @@ func HandleLocalApprove(ctx context.Context, cfg *config.Config, req LocalApprov
if cfg.IsRegistered() {
return nil, ErrApprovalFleetMode
}
if !cfg.IsStandalone() {
return nil, ErrApprovalNoStandaloneIdentity
}
if req.PackageType == "" || req.PackageName == "" {
return nil, fmt.Errorf("package_type and package_name are required")
}
if !gatedLocalApproval(req.PackageType) {
return nil, fmt.Errorf("local approval not supported for package_type=%s (dnf|apt only)", req.PackageType)
return nil, fmt.Errorf("local approval not supported for package_type=%s (dnf|apt|pacman only)", req.PackageType)
}
if req.Operator == "" {
return nil, fmt.Errorf("operator is required")
@ -72,6 +181,9 @@ func HandleLocalApprove(ctx context.Context, cfg *config.Config, req LocalApprov
log.Printf("[INFO] [agent] [localapprove] approval_started pkg=%s type=%s version=%s operator=%s",
req.PackageName, req.PackageType, req.AvailableVersion, req.Operator)
if req.PackageType == "pacman" {
return handleLocalPacmanApproval(ctx, cfg, req)
}
// Resolve the closure exactly as the fleet dry-run path does.
inst, err := installer.InstallerFactory(req.PackageType, cfg.ServerURL)

View file

@ -0,0 +1,49 @@
package handlers
import (
"context"
"errors"
"testing"
"github.com/Fimeg/RedFlag/agent/internal/config"
"github.com/gofrs/uuid/v5"
)
func TestGatedLocalApprovalEcosystems(t *testing.T) {
for _, packageType := range []string{"apt", "dnf", "pacman"} {
if !gatedLocalApproval(packageType) {
t.Errorf("%s is not routed through local authority", packageType)
}
}
for _, packageType := range []string{"docker", "winget", "windows_update", "cargo"} {
if gatedLocalApproval(packageType) {
t.Errorf("%s entered local authority without a migrated backend", packageType)
}
}
}
func TestLocalApprovalRefusesFleetModeBeforeResolution(t *testing.T) {
id, err := uuid.NewV4()
if err != nil {
t.Fatal(err)
}
_, err = HandleLocalApprove(context.Background(), &config.Config{AgentID: id, Token: "fleet"}, LocalApproveRequest{
PackageType: "pacman", PackageName: "linux", Operator: "operator", OverrideReason: "accepted",
})
if !errors.Is(err, ErrApprovalFleetMode) {
t.Fatalf("fleet approval error = %v", err)
}
}
func TestPacmanUnsupportedOSVRequiresReasonBeforeResolution(t *testing.T) {
id, err := uuid.NewV4()
if err != nil {
t.Fatal(err)
}
_, err = HandleLocalApprove(context.Background(), &config.Config{AgentID: id}, LocalApproveRequest{
PackageType: "pacman", PackageName: "not-a-real-package", Operator: "operator",
})
if !errors.Is(err, ErrApprovalBlocked) {
t.Fatalf("pacman approval error = %v", err)
}
}

View file

@ -50,6 +50,7 @@ func runStandaloneUpdateScan(cfg *config.Config, orch *orchestrator.Orchestrator
candidates = []updateScanner{
{"apt", scanner.NewAPTScanner().IsAvailable},
{"dnf", scanner.NewDNFScanner().IsAvailable},
{"pacman", scanner.NewPacmanScanner().IsAvailable},
}
case "windows":
candidates = []updateScanner{

View file

@ -0,0 +1,21 @@
package installer
// PacmanResolvedArtifact is one exact archive in the transaction pacman
// resolved for a requested package. Paths remain valid until Cleanup is called.
type PacmanResolvedArtifact struct {
Name string
Version string
Repository string
ArchivePath string
ArchiveSHA256 string
SignaturePath string
SignatureSHA256 string
}
// PacmanResolution owns the private sync database and cache backing a resolved
// transaction. The caller keeps it alive through helper execution, then calls
// Cleanup even when mint or execution refuses.
type PacmanResolution struct {
Artifacts []PacmanResolvedArtifact
Cleanup func()
}

View file

@ -0,0 +1,216 @@
//go:build linux
package installer
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"github.com/Fimeg/RedFlag/agent/internal/constants"
)
const pacmanCommandPath = "/usr/bin/pacman"
var pacmanResolverEnv = []string{
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
"LC_ALL=C",
"LANG=C",
}
func pacmanResolverCommand(name string, args ...string) ([]byte, error) {
cmd := exec.Command(name, args...)
cmd.Env = pacmanResolverEnv
output, err := cmd.CombinedOutput()
if err != nil {
message := strings.TrimSpace(string(output))
if len(message) > 4096 {
message = message[:4096]
}
return nil, fmt.Errorf("%s failed: %w: %s", filepath.Base(name), err, message)
}
return output, nil
}
func parsePacmanPrint(output string) (map[string]string, error) {
repositories := make(map[string]string)
for _, line := range strings.Split(strings.TrimSpace(output), "\n") {
if line == "" {
continue
}
fields := strings.Split(line, "|")
if len(fields) != 3 || fields[0] == "" || fields[1] == "" || fields[2] == "" {
return nil, fmt.Errorf("pacman printed malformed closure identity %q", line)
}
repositories[fields[0]+"@"+fields[1]] = fields[2]
}
if len(repositories) == 0 {
return nil, fmt.Errorf("pacman resolved an empty transaction")
}
return repositories, nil
}
func inspectPacmanArchive(path string) (string, string, error) {
output, err := pacmanResolverCommand(pacmanCommandPath, "-Qp", "--", path)
if err != nil {
return "", "", err
}
fields := strings.Fields(string(output))
if len(fields) != 2 || fields[0] == "" || fields[1] == "" {
return "", "", fmt.Errorf("pacman printed malformed archive identity for %s", filepath.Base(path))
}
return fields[0], fields[1], nil
}
func regularPacmanArtifact(path string) error {
info, err := os.Lstat(path)
if err != nil {
return err
}
if !info.Mode().IsRegular() {
return fmt.Errorf("not a regular file")
}
return nil
}
// ResolvePacmanClosure refreshes a private sync database, resolves the exact
// transaction, and downloads its archives plus detached signatures into an
// Agent-owned cache. It never mutates the host pacman database or package set.
func ResolvePacmanClosure(packageName, version string) (*PacmanResolution, error) {
if err := validatePackageName(packageName); err != nil {
return nil, err
}
if version != "" {
if err := validatePackageName(version); err != nil {
return nil, err
}
}
if _, err := os.Stat(pacmanCommandPath); err != nil {
return nil, fmt.Errorf("pacman is unavailable: %w", err)
}
if _, err := exec.LookPath("fakeroot"); err != nil {
return nil, fmt.Errorf("fakeroot is required for private pacman resolution: %w", err)
}
agentDir := filepath.Join(constants.GetBaseDir(), constants.AgentDir)
resolutionRoot := filepath.Join(agentDir, "pacman-resolve")
if err := os.MkdirAll(resolutionRoot, 0o700); err != nil {
return nil, fmt.Errorf("create pacman resolution root: %w", err)
}
root, err := os.MkdirTemp(resolutionRoot, "operation-")
if err != nil {
return nil, fmt.Errorf("create pacman resolution: %w", err)
}
cleanup := func() { _ = os.RemoveAll(root) }
fail := func(err error) (*PacmanResolution, error) {
cleanup()
return nil, err
}
database := filepath.Join(root, "db")
cache := filepath.Join(root, "cache")
if err := os.Mkdir(database, 0o700); err != nil {
return fail(fmt.Errorf("create pacman database: %w", err))
}
if err := os.Mkdir(cache, 0o700); err != nil {
return fail(fmt.Errorf("create pacman cache: %w", err))
}
if err := os.Symlink("/var/lib/pacman/local", filepath.Join(database, "local")); err != nil {
return fail(fmt.Errorf("bind installed pacman state: %w", err))
}
fakeroot, err := exec.LookPath("fakeroot")
if err != nil {
return fail(err)
}
if _, err := pacmanResolverCommand(
fakeroot, "--", pacmanCommandPath, "-Sy", "--noconfirm",
"--disable-sandbox-filesystem", "--dbpath", database, "--logfile", "/dev/null",
); err != nil {
return fail(fmt.Errorf("refresh private pacman metadata: %w", err))
}
target := packageName
if version != "" {
target += "=" + version
}
printOutput, err := pacmanResolverCommand(
pacmanCommandPath, "-Sp", "--print-format", "%n|%v|%r",
"--dbpath", database, "--", target,
)
if err != nil {
return fail(fmt.Errorf("resolve pacman transaction: %w", err))
}
repositories, err := parsePacmanPrint(string(printOutput))
if err != nil {
return fail(err)
}
if _, err := pacmanResolverCommand(
fakeroot, "--", pacmanCommandPath, "-Sw", "--noconfirm", "--needed",
"--disable-sandbox-filesystem", "--dbpath", database, "--cachedir", cache,
"--logfile", "/dev/null", "--", target,
); err != nil {
return fail(fmt.Errorf("download pacman transaction: %w", err))
}
entries, err := os.ReadDir(cache)
if err != nil {
return fail(fmt.Errorf("read pacman cache: %w", err))
}
artifacts := make([]PacmanResolvedArtifact, 0, len(entries)/2)
rootFound := false
for _, entry := range entries {
name := entry.Name()
if !strings.Contains(name, ".pkg.tar.") || strings.HasSuffix(name, ".sig") || strings.HasSuffix(name, ".part") {
continue
}
archivePath := filepath.Join(cache, name)
if err := regularPacmanArtifact(archivePath); err != nil {
return fail(fmt.Errorf("unsafe pacman archive %s: %w", name, err))
}
signaturePath := archivePath + ".sig"
if err := regularPacmanArtifact(signaturePath); err != nil {
return fail(fmt.Errorf("pacman archive %s has no regular detached signature: %w", name, err))
}
resolvedName, resolvedVersion, err := inspectPacmanArchive(archivePath)
if err != nil {
return fail(err)
}
repository, ok := repositories[resolvedName+"@"+resolvedVersion]
if !ok {
return fail(fmt.Errorf("pacman repository missing for %s@%s", resolvedName, resolvedVersion))
}
archiveHash, err := fileSHA256(archivePath)
if err != nil {
return fail(fmt.Errorf("hash pacman archive %s: %w", name, err))
}
signatureHash, err := fileSHA256(signaturePath)
if err != nil {
return fail(fmt.Errorf("hash pacman signature %s: %w", filepath.Base(signaturePath), err))
}
artifacts = append(artifacts, PacmanResolvedArtifact{
Name: resolvedName, Version: resolvedVersion, Repository: repository,
ArchivePath: archivePath, ArchiveSHA256: archiveHash,
SignaturePath: signaturePath, SignatureSHA256: signatureHash,
})
if resolvedName == packageName && (version == "" || resolvedVersion == version) {
rootFound = true
}
}
if len(artifacts) == 0 {
return fail(fmt.Errorf("pacman downloaded no package archives"))
}
if !rootFound {
return fail(fmt.Errorf("pacman transaction did not contain requested root %s@%s", packageName, version))
}
sort.Slice(artifacts, func(i, j int) bool {
if artifacts[i].Name == artifacts[j].Name {
return artifacts[i].Version < artifacts[j].Version
}
return artifacts[i].Name < artifacts[j].Name
})
return &PacmanResolution{Artifacts: artifacts, Cleanup: cleanup}, nil
}

View file

@ -0,0 +1,26 @@
//go:build linux
package installer
import "testing"
func TestParsePacmanPrint(t *testing.T) {
repositories, err := parsePacmanPrint("linux|1:6.19.14-1|core\nmkinitcpio|39.2-3|core\n")
if err != nil {
t.Fatal(err)
}
if got := repositories["linux@1:6.19.14-1"]; got != "core" {
t.Fatalf("epoch-bearing identity repository = %q, want core", got)
}
if got := repositories["mkinitcpio@39.2-3"]; got != "core" {
t.Fatalf("dependency repository = %q, want core", got)
}
}
func TestParsePacmanPrintRefusesMalformedIdentity(t *testing.T) {
for _, output := range []string{"", "linux 6.19 core", "linux|6.19|"} {
if _, err := parsePacmanPrint(output); err == nil {
t.Fatalf("parsePacmanPrint(%q) succeeded", output)
}
}
}

View file

@ -0,0 +1,9 @@
//go:build !linux
package installer
import "fmt"
func ResolvePacmanClosure(packageName, version string) (*PacmanResolution, error) {
return nil, fmt.Errorf("pacman resolution is only available on Linux")
}

View file

@ -164,6 +164,9 @@ func (e *Executor) Execute(ctx context.Context, token *capability.Token, extraAr
}
tokPath := filepath.Join(tokDir, fname)
resPath := filepath.Join(resDir, fname)
if err := os.Remove(resPath); err != nil && !os.IsNotExist(err) {
return nil, fmt.Errorf("clear stale result file: %w", err)
}
if err := os.WriteFile(tokPath, payload, 0o600); err != nil {
return nil, fmt.Errorf("write token file: %w", err)

View file

@ -100,8 +100,11 @@ func (e *Executor) Mint(ctx context.Context, req *MintRequest) (*capability.Toke
if err != nil {
return nil, err
}
reqPath := filepath.Join(reqDir, fname+".json")
tokPath := filepath.Join(tokDir, fname+".minted")
reqPath := filepath.Join(reqDir, fname)
tokPath := filepath.Join(tokDir, fname)
if err := os.Remove(tokPath); err != nil && !errors.Is(err, os.ErrNotExist) {
return nil, fmt.Errorf("clear stale minted token: %w", err)
}
if err := os.WriteFile(reqPath, payload, 0o640); err != nil {
return nil, fmt.Errorf("write mint request: %w", err)
}

View file

@ -0,0 +1,218 @@
package supplychain
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"time"
"github.com/Fimeg/RedFlag/agent/internal/capability"
"github.com/Fimeg/RedFlag/agent/internal/constants"
"github.com/gofrs/uuid/v5"
)
const (
mutationRequestDir = "mutation-requests"
mutationEnvelopeDir = "mutation-envelopes"
mutationReceiptDir = "mutation-receipts"
mutationMintTimeout = 2 * time.Minute
)
// EnvelopeMintRequest is the unprivileged request handed to the standalone
// root authority. The helper re-resolves trust from the manifest bytes and
// gate evidence before it signs anything.
type EnvelopeMintRequest struct {
Version int `json:"version"`
RequestID string `json:"request_id"`
Manifest capability.MutationManifest `json:"manifest"`
GateEvidence GateEvidence `json:"gate_evidence"`
}
func runMutationHelper(
ctx context.Context,
timeout time.Duration,
binaryPath string,
args ...string,
) (int, string, error) {
runCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
unitArgs := []string{
"systemd-run", "--wait", "--property=ProtectSystem=no", "--", binaryPath,
}
unitArgs = append(unitArgs, args...)
cmd := exec.CommandContext(runCtx, "sudo", unitArgs...)
var stderr bytes.Buffer
cmd.Stderr = &stderr
err := cmd.Run()
exitCode := 0
if err != nil {
exitCode = -1
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
exitCode = exitErr.ExitCode()
}
}
return exitCode, stderr.String(), err
}
// MintEnvelope asks the root-owned standalone authority to sign one immutable
// mutation manifest. It returns the mint request ID for the durable audit join.
func (e *Executor) MintEnvelope(
ctx context.Context,
manifest capability.MutationManifest,
gateEvidence GateEvidence,
) (*capability.MutationEnvelope, string, error) {
requestID, err := uuid.NewV4()
if err != nil {
return nil, "", fmt.Errorf("generate envelope request id: %w", err)
}
request := EnvelopeMintRequest{
Version: capability.MutationProtocolVersion, RequestID: requestID.String(),
Manifest: manifest, GateEvidence: gateEvidence,
}
if err := request.Manifest.Validate(); err != nil {
return nil, request.RequestID, err
}
payload, err := json.Marshal(&request)
if err != nil {
return nil, request.RequestID, fmt.Errorf("marshal envelope request: %w", err)
}
agentDir := filepath.Join(constants.GetBaseDir(), constants.AgentDir)
requestDir := filepath.Join(agentDir, mutationRequestDir)
envelopeDir := filepath.Join(agentDir, mutationEnvelopeDir)
if err := os.MkdirAll(requestDir, 0o700); err != nil {
return nil, request.RequestID, fmt.Errorf("create mutation request dir: %w", err)
}
if err := os.MkdirAll(envelopeDir, 0o700); err != nil {
return nil, request.RequestID, fmt.Errorf("create mutation envelope dir: %w", err)
}
filename, err := safeTokenFilename(request.RequestID)
if err != nil {
return nil, request.RequestID, err
}
requestPath := filepath.Join(requestDir, filename)
envelopePath := filepath.Join(envelopeDir, filename)
if err := os.Remove(envelopePath); err != nil && !errors.Is(err, os.ErrNotExist) {
return nil, request.RequestID, fmt.Errorf("clear stale mutation envelope: %w", err)
}
if err := os.WriteFile(requestPath, payload, 0o600); err != nil {
return nil, request.RequestID, fmt.Errorf("write mutation request: %w", err)
}
defer os.Remove(requestPath)
defer os.Remove(envelopePath)
exitCode, stderr, runErr := runMutationHelper(
ctx, mutationMintTimeout, e.BinaryPath,
"mint-envelope", "--request-file", requestPath, "--envelope-out", envelopePath,
)
if runErr != nil {
log.Printf("[SECURITY] [agent] [supplychain] envelope_mint_denied request_id=%s exit=%d stderr=%s",
request.RequestID, exitCode, stderr)
switch exitCode {
case mintExitGate:
return nil, request.RequestID, fmt.Errorf("%w (request_id=%s)", ErrMintGateRefused, request.RequestID)
case mintExitStale:
return nil, request.RequestID, fmt.Errorf("%w (request_id=%s)", ErrMintStale, request.RequestID)
case mintExitKey:
return nil, request.RequestID, fmt.Errorf("%w (request_id=%s)", ErrMintNoAuthority, request.RequestID)
case mintExitDuplicate:
return nil, request.RequestID, fmt.Errorf("%w (request_id=%s)", ErrMintDuplicate, request.RequestID)
default:
return nil, request.RequestID, fmt.Errorf("mint envelope failed exit=%d request_id=%s: %w", exitCode, request.RequestID, runErr)
}
}
raw, err := os.ReadFile(envelopePath)
if err != nil {
return nil, request.RequestID, fmt.Errorf("read minted envelope: %w", err)
}
var envelope capability.MutationEnvelope
if err := json.Unmarshal(raw, &envelope); err != nil {
return nil, request.RequestID, fmt.Errorf("parse minted envelope: %w", err)
}
if envelope.Manifest.Hash() != manifest.Hash() {
return nil, request.RequestID, fmt.Errorf("minted envelope manifest mismatch")
}
log.Printf("[SECURITY] [agent] [supplychain] envelope_mint_succeeded request_id=%s authorization_id=%s actions=%d",
request.RequestID, envelope.Authorization.AuthorizationID, len(envelope.Manifest.ResolvedActions))
return &envelope, request.RequestID, nil
}
// ExecuteEnvelope hands one signed envelope to the privileged verifier and
// returns its joined receipt, including denials and failed package execution.
func (e *Executor) ExecuteEnvelope(
ctx context.Context,
envelope *capability.MutationEnvelope,
) (*capability.MutationReceipt, error) {
if envelope == nil {
return nil, fmt.Errorf("mutation envelope is nil")
}
payload, err := json.Marshal(envelope)
if err != nil {
return nil, fmt.Errorf("marshal mutation envelope: %w", err)
}
agentDir := filepath.Join(constants.GetBaseDir(), constants.AgentDir)
envelopeDir := filepath.Join(agentDir, mutationEnvelopeDir)
receiptDir := filepath.Join(agentDir, mutationReceiptDir)
if err := os.MkdirAll(envelopeDir, 0o700); err != nil {
return nil, fmt.Errorf("create mutation envelope dir: %w", err)
}
if err := os.MkdirAll(receiptDir, 0o700); err != nil {
return nil, fmt.Errorf("create mutation receipt dir: %w", err)
}
filename, err := safeTokenFilename(envelope.Authorization.AuthorizationID)
if err != nil {
return nil, err
}
envelopePath := filepath.Join(envelopeDir, filename)
receiptPath := filepath.Join(receiptDir, filename)
if err := os.Remove(receiptPath); err != nil && !errors.Is(err, os.ErrNotExist) {
return nil, fmt.Errorf("clear stale mutation receipt: %w", err)
}
if err := os.WriteFile(envelopePath, payload, 0o600); err != nil {
return nil, fmt.Errorf("write mutation envelope: %w", err)
}
defer os.Remove(envelopePath)
defer os.Remove(receiptPath)
exitCode, stderr, runErr := runMutationHelper(
ctx, executorTimeout, e.BinaryPath,
"execute-envelope", "--envelope-file", envelopePath, "--receipt-file", receiptPath,
)
if stderr != "" {
log.Printf("[INFO] [agent] [supplychain] envelope_executor_log %s", stderr)
}
raw, err := os.ReadFile(receiptPath)
if err != nil {
if runErr != nil {
return nil, fmt.Errorf("helper failed without mutation receipt exit=%d: %w", exitCode, runErr)
}
return nil, fmt.Errorf("helper returned without mutation receipt: %w", err)
}
var receipt capability.MutationReceipt
if err := json.Unmarshal(bytes.TrimSpace(raw), &receipt); err != nil {
return nil, fmt.Errorf("parse mutation receipt: %w", err)
}
if receipt.AuthorizationID != envelope.Authorization.AuthorizationID ||
receipt.OperationID != envelope.Manifest.OperationID ||
receipt.ManifestHash != envelope.Manifest.Hash() ||
receipt.TargetID != envelope.Manifest.TargetID ||
receipt.ProtocolVersion != capability.MutationProtocolVersion ||
receipt.Backend != envelope.Manifest.Backend ||
receipt.Operation != envelope.Manifest.Operation {
return nil, fmt.Errorf("mutation receipt audit join mismatch")
}
if receipt.VerifiedActions < 0 || receipt.VerifiedActions > len(envelope.Manifest.ResolvedActions) {
return nil, fmt.Errorf("mutation receipt verified action count is outside the signed manifest")
}
if receipt.Executed != (receipt.Decision == "executed" && receipt.ExitCode == 0) {
return nil, fmt.Errorf("mutation receipt outcome is internally inconsistent")
}
return &receipt, nil
}

View file

@ -22,6 +22,7 @@ const (
OSVStatusClear = "clear"
OSVStatusVulnerable = "vulnerable"
OSVStatusUnreachable = "unreachable"
OSVStatusUnsupported = "unsupported"
)
// osvBatchLimit mirrors the server's batch sizing for /v1/querybatch.

View file

@ -134,7 +134,7 @@ ApplicationWindow {
// actually returned rather than by a hardcoded chip.
function stageState(index) {
const a = approval;
const policy = a.policy || {
const result = a.receipt || a.policy || {
};
if (index === 0)
return "done";
@ -152,10 +152,10 @@ ApplicationWindow {
return "done";
if (index === 2)
return policy.token_id ? "done" : "failed";
return (result.authorization_id || result.token_id) ? "done" : "failed";
if (index === 3)
return policy.executed ? "done" : "failed";
return result.executed ? "done" : "failed";
return "idle";
}
@ -174,7 +174,7 @@ ApplicationWindow {
}
function locallyApprovable(u) {
return !!u && (u.package_type === "dnf" || u.package_type === "apt");
return !!u && (u.package_type === "dnf" || u.package_type === "apt" || u.package_type === "pacman");
}
function approvalBlockReason(u) {
@ -185,11 +185,35 @@ ApplicationWindow {
return "This machine is fleet enrolled — authority belongs to the RedFlag Server, and the Agent refuses a local mint.";
if (!locallyApprovable(u))
return "Local approval covers DNF and APT, the ecosystems whose closure the Agent can resolve and pin from signed repository metadata. " + String(u.package_type || "this ecosystem").toUpperCase() + " still executes through the fleet signed-command path.";
return "Local approval covers DNF, APT, and pacman. " + String(u.package_type || "this ecosystem").toUpperCase() + " still executes through the fleet signed-command path.";
return "";
}
function approvalReasonRequired(u) {
return !!u && u.package_type === "pacman";
}
function approvalCanOverride() {
return String(approval.error || "").indexOf("osv_status=") >= 0;
}
function approvalResult() {
return approval.receipt || approval.policy || {
};
}
function verifiedActionCount() {
const result = approvalResult();
if (result.verified_actions !== undefined)
return result.verified_actions;
if (result.verified_artifacts !== undefined)
return result.verified_artifacts;
return "—";
}
function processCountLabel(n) {
return n ? n + (n === 1 ? " process" : " processes") : "";
}
@ -668,6 +692,25 @@ ApplicationWindow {
}
Rectangle {
visible: machine.connected && machine.agentGap.length > 0
Layout.fillWidth: true
Layout.preferredHeight: visible ? gapText.implicitHeight + 20 : 0
color: Theme.tint(Theme.warn, 0.12)
Text {
id: gapText
anchors.fill: parent
anchors.margins: 10
text: machine.agentGap
color: Theme.warn
font.pixelSize: 11
wrapMode: Text.Wrap
}
}
Rectangle {
visible: !machine.connected && machine.connectionError.length > 0
Layout.fillWidth: true
@ -3458,10 +3501,10 @@ ApplicationWindow {
"detail": app.selectedUpdate.repository_source || "reported by the scanner"
}, {
"label": "Checked",
"detail": "closure resolved · hashes pinned · OSV"
"detail": app.selectedUpdate.package_type === "pacman" ? "closure · hashes · package signatures" : "closure resolved · hashes pinned · OSV"
}, {
"label": "Authorized",
"detail": "capability minted for this host"
"detail": app.selectedUpdate.package_type === "pacman" ? "mutation envelope signed for this host" : "capability minted for this host"
}, {
"label": "Installed",
"detail": "helper verified and executed"
@ -3592,22 +3635,16 @@ ApplicationWindow {
"v": String(app.approval.osv_vuln_count !== undefined ? app.approval.osv_vuln_count : "—")
}, {
"l": "Verified artifacts",
"v": String((app.approval.policy || {
}).verified_artifacts !== undefined ? (app.approval.policy || {
}).verified_artifacts : "—")
"v": String(app.verifiedActionCount())
}, {
"l": "Decision",
"v": (app.approval.policy || {
}).decision || "—"
"v": app.approvalResult().decision || "—"
}, {
"l": "Exit code",
"v": String((app.approval.policy || {
}).exit_code !== undefined ? (app.approval.policy || {
}).exit_code : "—")
"v": String(app.approvalResult().exit_code !== undefined ? app.approvalResult().exit_code : "—")
}, {
"l": "Executed",
"v": (app.approval.policy || {
}).executed ? "yes" : "no"
"v": app.approvalResult().executed ? "yes" : "no"
}, {
"l": "Request",
"v": String(app.approval.request_id || "—").substring(0, 12)
@ -3650,8 +3687,7 @@ ApplicationWindow {
Text {
visible: text.length > 0
Layout.fillWidth: true
text: app.approval.error || ((app.approval.policy || {
}).reason || "")
text: app.approval.error || app.approvalResult().reason || ""
color: app.approval.error ? Theme.bad : Theme.dim
font.pixelSize: 11
font.family: Theme.mono
@ -3678,13 +3714,33 @@ ApplicationWindow {
}
Rectangle {
visible: app.approvalReasonRequired(app.selectedUpdate)
Layout.fillWidth: true
Layout.preferredHeight: pacmanEvidenceText.implicitHeight + 22
color: Theme.raised
radius: Theme.radiusSm
border.width: 1
border.color: Theme.warn
Text {
id: pacmanEvidenceText
anchors.fill: parent
anchors.margins: 11
text: "OSV has no Arch Linux package mapping in this path. RedFlag can resolve and cryptographically verify the exact pacman transaction, but it cannot call the dependency closure advisory-clear. State why you accept that uncertainty; the root helper records the reason beside the request and authorization in its journal."
color: Theme.warn
font.pixelSize: 11
wrapMode: Text.Wrap
}
}
CheckBox {
id: overrideToggle
visible: app.approvalBlockReason(app.selectedUpdate).length === 0 && !(app.approval.policy || {
}).executed
text: "Override policy refusal with recorded operator intent"
checked: String(app.approval.error || "").indexOf("osv_status") >= 0
visible: !app.approvalReasonRequired(app.selectedUpdate) && app.approvalCanOverride() && app.approvalBlockReason(app.selectedUpdate).length === 0 && !app.approvalResult().executed
text: "Override this advisory refusal with recorded operator intent"
onToggled: {
if (!checked) {
overrideField.text = "";
@ -3703,10 +3759,10 @@ ApplicationWindow {
TextArea {
id: overrideField
visible: overrideToggle.checked
visible: app.approvalReasonRequired(app.selectedUpdate) || overrideToggle.checked
Layout.fillWidth: true
Layout.preferredHeight: 76
placeholderText: "Why the operator is breaking glass. This reason is bound into the same authorization and durable history."
placeholderText: app.approvalReasonRequired(app.selectedUpdate) ? "Why this exact signed pacman transaction should proceed without OSV advisory coverage." : "Why the operator is breaking glass. The root helper records this reason beside the authorization in durable history."
wrapMode: TextEdit.Wrap
color: Theme.text
placeholderTextColor: Theme.faint
@ -3723,7 +3779,7 @@ ApplicationWindow {
Text {
Layout.fillWidth: true
Layout.topMargin: 4
text: "Approval resolves the dependency closure, pins every artifact hash it can, checks the set against OSV, mints an Ed25519 capability bound to this host, and hands it to the privileged helper. The helper verifies independently. This window never touches a package manager."
text: app.selectedUpdate.package_type === "pacman" ? "The Agent resolves a private pacman transaction and downloads every exact archive and detached signature. The root helper stages and verifies identity, hashes, Arch signatures, and forward-only versions before signing the mutation envelope, then repeats those checks before pacman runs. OSV has no Arch mapping here, so acceptance stays explicit. This window never touches pacman." : "Approval resolves the dependency closure, pins every artifact hash it can, checks the set against OSV, mints an Ed25519 capability bound to this host, and hands it to the privileged helper. The helper verifies independently. This window never touches a package manager."
color: Theme.faint
font.pixelSize: 10
wrapMode: Text.Wrap
@ -3735,12 +3791,10 @@ ApplicationWindow {
footer: DialogButtonBox {
Button {
text: machine.approvalRunning ? "Authorizing…" : ((app.approval.policy || {
}).executed ? "Installed" : overrideToggle.checked ? "Override and install" : "Approve and install")
enabled: !machine.approvalRunning && app.approvalBlockReason(app.selectedUpdate).length === 0 && !(app.approval.policy || {
}).executed && (!overrideToggle.checked || overrideField.text.trim().length > 0)
text: machine.approvalRunning ? "Authorizing…" : (app.approvalResult().executed ? "Installed" : app.approvalReasonRequired(app.selectedUpdate) ? "Approve with reason and install" : overrideToggle.checked ? "Override and install" : "Approve and install")
enabled: !machine.approvalRunning && app.approvalBlockReason(app.selectedUpdate).length === 0 && !app.approvalResult().executed && (!(app.approvalReasonRequired(app.selectedUpdate) || overrideToggle.checked) || overrideField.text.trim().length > 0)
DialogButtonBox.buttonRole: DialogButtonBox.AcceptRole
onClicked: machine.approveUpdate(app.selectedUpdate.package_type || "", app.selectedUpdate.package_name || "", app.selectedUpdate.available_version || "", overrideToggle.checked ? overrideField.text.trim() : "")
onClicked: machine.approveUpdate(app.selectedUpdate.package_type || "", app.selectedUpdate.package_name || "", app.selectedUpdate.available_version || "", app.approvalReasonRequired(app.selectedUpdate) || overrideToggle.checked ? overrideField.text.trim() : "")
background: Rectangle {
color: !parent.enabled ? Theme.raised : parent.pressed ? Qt.darker(Theme.red, 1.15) : Theme.red

File diff suppressed because it is too large Load diff

View file

@ -635,7 +635,12 @@ mod tests {
#[test]
fn evidence_carries_digests_not_prose() {
let fixture = fixture();
for bad in ["", "operator accepted the CVE risk", &"a".repeat(63), &"z".repeat(64)] {
for bad in [
"",
"operator accepted the CVE risk",
&"a".repeat(63),
&"z".repeat(64),
] {
let mut changed = fixture.manifest.clone();
changed.evidence[0].digest = bad.into();
assert!(changed.validate().is_err(), "validated digest {bad:?}");

View file

@ -6,8 +6,9 @@
# Run as root, after the base agent install (agent user, redflag-local group,
# helper binary, helper sudoers). The future native installers (.rpm/.deb/AUR)
# call this for standalone installs; fleet installs must NOT run it — fleet
# hosts have no local authority. Fleet join later runs:
# redflag-helper mint --retire-key
# hosts have no local authority. The helper has a key-retirement primitive, but
# the full standalone-to-fleet transition is not implemented; the Agent refuses
# registration while local standalone identity is active.
#
# Idempotent: safe to re-run. The key init refuses to overwrite an existing
# authority by design (retire first).
@ -16,20 +17,41 @@ set -euo pipefail
AGENT_USER="redflag-agent"
LOCAL_GROUP="redflag-local"
AGENT_BIN="/usr/local/bin/redflag-agent"
HELPER_BIN="/usr/local/bin/redflag-helper"
AGENT_CONFIG="/etc/redflag/agent/config.json"
AGENT_ID_FILE="/etc/redflag/agent_id"
JOURNAL_DIR="/var/lib/redflag/journal"
MINT_REQUEST_DIR="/var/lib/redflag/agent/mint"
TOKENS_DIR="/var/lib/redflag/agent/tokens"
MUTATION_REQUEST_DIR="/var/lib/redflag/agent/mutation-requests"
MUTATION_ENVELOPE_DIR="/var/lib/redflag/agent/mutation-envelopes"
MUTATION_RECEIPT_DIR="/var/lib/redflag/agent/mutation-receipts"
SUDOERS_FILE="/etc/sudoers.d/redflag-agent-mint"
log() { echo "[INFO] [provision] [standalone-authority] $*"; }
fail() { echo "[ERROR] [provision] [standalone-authority] $*" >&2; exit 1; }
[ "$(id -u)" -eq 0 ] || fail "must run as root"
[ -x "$AGENT_BIN" ] || fail "agent binary missing at $AGENT_BIN — run the base install first"
[ -x "$HELPER_BIN" ] || fail "helper binary missing at $HELPER_BIN — run the base install first"
[ -f "$AGENT_CONFIG" ] || fail "agent config missing at $AGENT_CONFIG — run the base install first"
id "$AGENT_USER" &>/dev/null || fail "agent user $AGENT_USER missing — run the base install first"
getent group "$LOCAL_GROUP" &>/dev/null || fail "group $LOCAL_GROUP missing — run the base install first"
# A standalone host still needs one stable identity for signed target binding.
# The Agent creates it once in config; this root provisioning step copies the
# same UUID into the helper's independent bind file.
STANDALONE_ID="$(runuser -u "$AGENT_USER" -- "$AGENT_BIN" --config "$AGENT_CONFIG" --init-standalone)" \
|| fail "standalone Agent identity initialization failed"
if [[ ! "$STANDALONE_ID" =~ ^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$ ]]; then
fail "standalone Agent returned an invalid identity"
fi
printf '%s' "$STANDALONE_ID" > "$AGENT_ID_FILE"
chown root:root "$AGENT_ID_FILE"
chmod 0644 "$AGENT_ID_FILE"
log "standalone identity bound: $STANDALONE_ID"
# Journal dir: root-owned, setgid redflag-local, group-readable. Files the
# helper writes 0640 inherit the group via setgid, so the unprivileged agent
# (a group member) can serve journal entries over the local API while the dir
@ -41,6 +63,10 @@ log "journal dir ready: $JOURNAL_DIR (root:$LOCAL_GROUP 2750)"
install -d -m 0750 -o "$AGENT_USER" -g "$LOCAL_GROUP" "$MINT_REQUEST_DIR"
log "mint request dir ready: $MINT_REQUEST_DIR"
install -d -m 0700 -o "$AGENT_USER" -g "$AGENT_USER" \
"$MUTATION_REQUEST_DIR" "$MUTATION_ENVELOPE_DIR" "$MUTATION_RECEIPT_DIR"
log "mutation exchange dirs ready"
# Tokens dir should already exist from the base install; ensure it does.
[ -d "$TOKENS_DIR" ] || install -d -m 0700 -o "$AGENT_USER" -g "$AGENT_USER" "$TOKENS_DIR"
@ -58,12 +84,14 @@ fi
# Sudoers: the agent user may invoke exactly the mint command shape, mirroring
# the execute-path grant. Request and token paths are pinned to their dirs.
cat > "$SUDOERS_FILE" <<EOF
# RedFlag standalone authority — mint invocation (FEAT-003).
# The agent user may request a mint; the gates + root-owned key decide.
# RedFlag standalone authority — fixed helper protocols (FEAT-003 / ARCH-002).
# The agent may request authority; the gates, signatures, and root-owned key decide.
$AGENT_USER ALL=(root) NOPASSWD: /usr/bin/systemd-run --wait --property=ProtectSystem=no -- $HELPER_BIN mint --request-file $MINT_REQUEST_DIR/* --token-out $TOKENS_DIR/*
$AGENT_USER ALL=(root) NOPASSWD: /usr/bin/systemd-run --wait --property=ProtectSystem=no -- $HELPER_BIN mint-envelope --request-file $MUTATION_REQUEST_DIR/* --envelope-out $MUTATION_ENVELOPE_DIR/*
$AGENT_USER ALL=(root) NOPASSWD: /usr/bin/systemd-run --wait --property=ProtectSystem=no -- $HELPER_BIN execute-envelope --envelope-file $MUTATION_ENVELOPE_DIR/* --receipt-file $MUTATION_RECEIPT_DIR/*
EOF
chmod 0440 "$SUDOERS_FILE"
visudo -c -f "$SUDOERS_FILE" >/dev/null || fail "sudoers validation failed for $SUDOERS_FILE"
log "sudoers installed: $SUDOERS_FILE"
log "standalone authority provisioned — fleet join later must run: $HELPER_BIN mint --retire-key"
log "standalone authority provisioned — fleet join is not implemented; do not add fleet credentials beside this key"

View file

@ -307,8 +307,6 @@ case "$PM" in
# Disable lecture (first-time prompt) — agent runs as a service with no TTY
Defaults:{{.AgentUser}} !lecture
# Mutation — ONLY through the capability-token executor
{{.AgentUser}} ALL=(root) NOPASSWD: /usr/bin/systemd-run --wait --property=ProtectSystem=no -- /usr/local/bin/redflag-helper --token-file /var/lib/redflag/agent/tokens/* --result-file /var/lib/redflag/agent/results/*
EOF
;;
dnf|yum)
@ -320,21 +318,17 @@ EOF
# Disable lecture (first-time prompt) — agent runs as a service with no TTY
Defaults:{{.AgentUser}} !lecture
# Mutation — ONLY through the capability-token executor
{{.AgentUser}} ALL=(root) NOPASSWD: /usr/bin/systemd-run --wait --property=ProtectSystem=no -- /usr/local/bin/redflag-helper --token-file /var/lib/redflag/agent/tokens/* --result-file /var/lib/redflag/agent/results/*
EOF
;;
pacman)
cat <<'EOF' | sudo tee "$SUDOERS_FILE" > /dev/null
# RedFlag Agent minimal sudo permissions - Pacman
# Discovery — non-mutating (read-only)
{{.AgentUser}} ALL=(root) NOPASSWD: /usr/bin/pacman -Sy
# Discovery and closure resolution use an unprivileged private pacman database.
# The host sync database is never refreshed through sudo.
# Disable lecture (first-time prompt) — agent runs as a service with no TTY
Defaults:{{.AgentUser}} !lecture
# Mutation — ONLY through the capability-token executor
{{.AgentUser}} ALL=(root) NOPASSWD: /usr/bin/systemd-run --wait --property=ProtectSystem=no -- /usr/local/bin/redflag-helper --token-file /var/lib/redflag/agent/tokens/* --result-file /var/lib/redflag/agent/results/*
EOF
;;
*)
@ -346,17 +340,23 @@ EOF
# Disable lecture (first-time prompt) — agent runs as a service with no TTY
Defaults:{{.AgentUser}} !lecture
# Mutation — ONLY through the capability-token executor
{{.AgentUser}} ALL=(root) NOPASSWD: /usr/bin/systemd-run --wait --property=ProtectSystem=no -- /usr/local/bin/redflag-helper --token-file /var/lib/redflag/agent/tokens/* --result-file /var/lib/redflag/agent/results/*
EOF
;;
esac
# Fleet hosts receive capability-token execution only. Local mint and envelope
# authority are provisioned by
# scripts/provision-standalone-authority.sh and must never exist beside a fleet
# credential. Package-manager commands never appear in sudoers.
cat <<'EOF' | sudo tee -a "$SUDOERS_FILE" > /dev/null
{{.AgentUser}} ALL=(root) NOPASSWD: /usr/bin/systemd-run --wait --property=ProtectSystem=no -- /usr/local/bin/redflag-helper --token-file /var/lib/redflag/agent/tokens/* --result-file /var/lib/redflag/agent/results/*
EOF
# No Docker grants: the agent reaches the docker socket via the docker group
# (added above), so docker discovery/pull need no sudo.
# No self-update grants: the binary swap (cp/.bak/chmod/systemctl restart) is
# performed by the privileged helper under an agent-self capability token, not by
# the agent. The single systemd-run helper line above is the agent's only sudo.
# performed by the privileged helper under a signed capability, not by the
# agent. The fixed helper file protocol above is the agent's only sudo.
sudo chmod 440 "$SUDOERS_FILE"
if visudo -c -f "$SUDOERS_FILE" &>/dev/null; then
@ -366,12 +366,12 @@ else
fi
# Step 4b: Install polkit rule for transient unit management
# The agent's only sudo is `systemd-run --pipe ... redflag-helper`. systemd-run
# The agent's only sudo is a fixed `systemd-run ... redflag-helper` protocol. systemd-run
# spawns a transient unit over the system D-Bus, which polkit gates behind
# org.freedesktop.systemd1.manage-units (auth_admin). polkit evaluates the
# original caller ({{.AgentUser}}), so without this rule the helper invocation —
# and therefore every gated install and the agent self-update — is denied on a
# TTY-less service. The sudoers line above already pins the exact command; this
# TTY-less service. The sudoers line above pins the exact command shape; this
# grants the matching D-Bus permission to the same user, nothing wider.
POLKIT_RULES_DIR="/etc/polkit-1/rules.d"
POLKIT_RULE_FILE="${POLKIT_RULES_DIR}/50-redflag-agent.rules"