Watch
1
0
Fork
You've already forked RedFlag
0
RedFlag/agent/internal/handlers/local_approve.go
Fimeg f487de554a desktop: become the machine console
Qt/QML now carries 11 local views while the Agent owns observation and intent. Tauri, WebKit, and the second React desktop build leave together. Linux ships first; Windows waits for a native Qt runner.
2026-09-01 08:29:58 -04:00

160 lines
6.2 KiB
Go

package handlers
import (
"context"
"errors"
"fmt"
"log"
"time"
"github.com/Fimeg/RedFlag/agent/internal/capability"
"github.com/Fimeg/RedFlag/agent/internal/config"
"github.com/Fimeg/RedFlag/agent/internal/installer"
"github.com/Fimeg/RedFlag/agent/internal/supplychain"
)
// Standalone local approval (FEAT-003). The full gate pipeline runs locally:
// dry-run closure resolve → hash pin → OSV best-effort → mint request → the
// privileged helper signs with the root-owned authority → the normal execute
// path verifies and installs. Fleet mode refuses: the server is the sole
// authority there, and this path never weakens that.
// Typed failures the local API maps to honest HTTP codes.
var (
// ErrApprovalFleetMode: this host is registered to a fleet server.
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")
)
// LocalApproveRequest is RedFlag Desktop's approval submission.
type LocalApproveRequest struct {
PackageType string `json:"package_type"`
PackageName string `json:"package_name"`
AvailableVersion string `json:"available_version"`
Operator string `json:"operator"`
OverrideReason string `json:"override_reason"`
}
// 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"`
}
// 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"
}
// HandleLocalApprove runs the standalone approval flow end to end. Synchronous:
// the caller holds the local socket connection until the helper's verdict (and,
// on success, the install) completes.
func HandleLocalApprove(ctx context.Context, cfg *config.Config, req LocalApproveRequest) (*LocalApproveResult, error) {
if cfg.IsRegistered() {
return nil, ErrApprovalFleetMode
}
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)
}
if req.Operator == "" {
return nil, fmt.Errorf("operator is required")
}
log.Printf("[INFO] [agent] [localapprove] approval_started pkg=%s type=%s version=%s operator=%s",
req.PackageName, req.PackageType, req.AvailableVersion, req.Operator)
// Resolve the closure exactly as the fleet dry-run path does.
inst, err := installer.InstallerFactory(req.PackageType, cfg.ServerURL)
if err != nil {
return nil, fmt.Errorf("[ERROR] [agent] [installer] factory_failed type=%s error=%w", req.PackageType, err)
}
if !inst.IsAvailable() {
return nil, fmt.Errorf("[ERROR] [agent] [installer] not_available type=%s", req.PackageType)
}
dryRun, err := inst.DryRun(req.PackageName, req.AvailableVersion)
if err != nil {
return nil, fmt.Errorf("dry run failed: %w", err)
}
resolvedAt := time.Now().UTC()
closureItems := resolveClosureHashes(req.PackageType, req.PackageName, req.AvailableVersion, dryRun.Dependencies)
if len(closureItems) == 0 {
// No pin, no mint. The execute path could not verify anything.
return nil, fmt.Errorf("closure hash resolution failed for %s — refusing unpinned approval", req.PackageName)
}
closure := make([]capability.ClosureEntry, len(closureItems))
pkgs := make([]supplychain.PkgVersion, len(closureItems))
for i, c := range closureItems {
closure[i] = capability.ClosureEntry{
Name: c.Name,
Version: c.Version,
SHA256: c.SHA256,
Source: c.Source,
}
pkgs[i] = supplychain.PkgVersion{Name: c.Name, Version: c.Version}
}
// OSV best-effort with honest verdict: vulnerable or unreachable proceeds
// only over an explicit operator reason, refused here before the helper is
// ever invoked (the helper re-enforces — defense in depth).
osvStatus, vulnCount := supplychain.CheckClosureOSV(ctx, req.PackageType, pkgs)
osvCheckedAt := time.Now().UTC()
if osvStatus != supplychain.OSVStatusClear && req.OverrideReason == "" {
log.Printf("[SECURITY] [agent] [localapprove] approval_blocked pkg=%s osv_status=%s vulns=%d",
req.PackageName, osvStatus, vulnCount)
return nil, fmt.Errorf("%w: osv_status=%s vulns=%d — override requires an explicit reason",
ErrApprovalBlocked, osvStatus, vulnCount)
}
mintReq := &supplychain.MintRequest{
AgentID: cfg.AgentID.String(),
PackageType: req.PackageType,
Operation: "install",
Closure: closure,
GateEvidence: supplychain.GateEvidence{
ResolvedAt: resolvedAt.Unix(),
OSVCheckedAt: osvCheckedAt.Unix(),
OSVStatus: osvStatus,
OSVVulnCount: vulnCount,
// Standalone has no registry age data for dnf/apt (matches the
// fleet age gate's ecosystem scope) and no local version
// first-seen tracking yet — journaled honestly as not_applicable.
AgeGate: "not_applicable",
SoakGate: "not_applicable",
Operator: req.Operator,
OverrideReason: req.OverrideReason,
},
}
executor := supplychain.NewExecutor("")
token, err := executor.Mint(ctx, mintReq)
if err != nil {
return nil, err
}
policy, err := executor.Execute(ctx, token)
if err != nil {
return nil, fmt.Errorf("execute after mint failed (token_id=%s): %w", token.TokenID, err)
}
log.Printf("[INFO] [agent] [localapprove] approval_completed pkg=%s decision=%s exit=%d",
req.PackageName, policy.Decision, policy.ExitCode)
return &LocalApproveResult{
RequestID: mintReq.RequestID,
OSVStatus: osvStatus,
OSVVulnCount: vulnCount,
ClosureSize: len(closure),
Policy: policy,
}, nil
}