Watch
1
0
Fork
You've already forked RedFlag
0
RedFlag/server/internal/api/handlers/updates.go

3394 lines
127 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package handlers
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"strconv"
"strings"
"time"
"github.com/Fimeg/RedFlag/server/internal/capability"
"github.com/Fimeg/RedFlag/server/internal/config"
"github.com/Fimeg/RedFlag/server/internal/database/queries"
"github.com/Fimeg/RedFlag/server/internal/models"
"github.com/Fimeg/RedFlag/server/internal/services"
"github.com/Fimeg/RedFlag/server/internal/utils"
"github.com/gin-gonic/gin"
"github.com/gofrs/uuid/v5"
)
// isValidResult checks if the result value complies with the database constraint.
// The constraint on update_logs.result is the authority (see migration 048); keep
// this map in sync.
func isValidResult(result string) bool {
validResults := map[string]bool{
"success": true,
"failed": true,
"partial": true,
"started": true,
"running": true,
}
return validResults[result]
}
// UpdateHandler handles package update operations
// DEPRECATED: This handler is being consolidated - will be replaced by unified update handling
type UpdateHandler struct {
updateQueries *queries.UpdateQueries
agentQueries *queries.AgentQueries
commandQueries *queries.CommandQueries
agentHandler *AgentHandler
maintenanceWindowQueries *queries.MaintenanceWindowQueries
securitySettings *services.SecuritySettingsService // optional; reads policy.allow_dry_runs
config *config.Config // optional; for self-referential URLs
minter *services.CapabilityMinter // optional; mints capability tokens at approval
tokenQueries *queries.CapabilityTokenQueries // optional; delivers minted tokens to agents
orchestrator LifecycleAdvancer // optional; auto-approve fast-path after a scan
reconciler ReconcilerTrigger // optional; auto-bind tracked software after a scan
runner BackgroundRunner // optional; bounded fire-and-forget pool (SCALE-001 S2)
}
// BackgroundRunner is the bounded fire-and-forget pool (SCALE-001 S6/S2). The
// report path used to launch raw `go func()` calls with no backpressure; routing
// them through a runner caps steady-state concurrency and makes saturation
// observable. Implemented by *taskrunner.Runner.
type BackgroundRunner interface {
Go(name string, fn func())
}
// LifecycleAdvancer is the orchestrator hook the update handlers fire after a
// scan report lands, so newly-discovered packages can be auto-approved
// immediately instead of waiting for the next orchestrator timer tick. It is
// declared here (not imported from the orchestrator package) so the dependency
// runs one way: orchestrator -> handlers, never back.
type LifecycleAdvancer interface {
OnPackagesDiscovered()
// OnDependenciesReported is fired after an agent reports a dependency
// closure (the package has just entered pending_dependencies), so an
// eligible capability-gated package is auto-confirmed and its token minted
// without waiting for the next orchestrator timer tick.
OnDependenciesReported(updateID uuid.UUID)
}
// ReconcilerTrigger is the hook the update handler fires after new packages
// are discovered so the reconciler can attempt automatic binding.
type ReconcilerTrigger interface {
ReconcileAll(ctx context.Context)
}
func NewUpdateHandler(uq *queries.UpdateQueries, aq *queries.AgentQueries, cq *queries.CommandQueries, ah *AgentHandler, mwq *queries.MaintenanceWindowQueries, cfg *config.Config) *UpdateHandler {
return &UpdateHandler{
updateQueries: uq,
agentQueries: aq,
commandQueries: cq,
agentHandler: ah,
maintenanceWindowQueries: mwq,
config: cfg,
}
}
// SetSecuritySettings injects the settings service so this handler can read
// policy.allow_dry_runs at request time. Nil leaves the strict default (dry
// run required) in place.
func (h *UpdateHandler) SetSecuritySettings(s *services.SecuritySettingsService) {
h.securitySettings = s
}
// SetCapabilityMinter wires the capability-token minter and token store. When
// both are set, approval mints a signed token over the package closure and the
// delivery endpoint serves it to the agent. Nil leaves the gate inactive.
func (h *UpdateHandler) SetCapabilityMinter(m *services.CapabilityMinter, tq *queries.CapabilityTokenQueries) {
h.minter = m
h.tokenQueries = tq
}
// SetOrchestrator wires the lifecycle orchestrator's synchronous discovery hook.
// Nil leaves auto-approval to the orchestrator's own timer sweep.
func (h *UpdateHandler) SetOrchestrator(o LifecycleAdvancer) {
h.orchestrator = o
}
// SetReconciler wires the automatic reconciliation service.
func (h *UpdateHandler) SetReconciler(r ReconcilerTrigger) {
h.reconciler = r
}
// SetTaskRunner wires the bounded background pool. Nil falls back to launching a
// plain goroutine per task (legacy unbounded behavior).
func (h *UpdateHandler) SetTaskRunner(r BackgroundRunner) {
h.runner = r
}
// bg runs fn on the bounded pool when a runner is wired, else as a plain
// goroutine. Keeps the report path's fire-and-forget work bounded and named.
func (h *UpdateHandler) bg(name string, fn func()) {
if h.runner != nil {
h.runner.Go(name, fn)
return
}
go fn()
}
// EnqueueDryRun creates and signs the dry_run_update command for a package and
// advances it to checking_dependencies, queuing a short heartbeat so the agent
// polls promptly. Shared by the operator-triggered dry-run endpoint and the
// orchestrator's auto-approve path. Policy (allow_dry_runs) and maintenance-window
// checks are the caller's responsibility — by the time this runs, the decision to
// dry-run has been made.
func (h *UpdateHandler) EnqueueDryRun(update *models.UpdateState) error {
// GATE-005: use selected_version when set, else available_version.
// "target_version" is the version to install; "available_version" carries the
// latest upstream version so the agent can compare for freshness checks.
params := map[string]interface{}{
"update_id": update.ID.String(),
"package_name": update.PackageName,
"package_type": update.PackageType,
"available_version": update.AvailableVersion,
}
// Target precedence: manual selected_version wins; installed_hold keeps the
// installed version pinned until resolveGatedTarget finds a newer version that
// cleared soak. Absent either, RedFlag auto-pins the soak-gated version
// (forward-only, block-enforced). [GATE-006 B] Falling through to no
// target_version means the agent installs available_version, the legacy default.
if targetVersion, explicit := h.resolveInstallTarget(update); explicit {
params["target_version"] = targetVersion
}
command := &models.AgentCommand{
ID: uuid.Must(uuid.NewV4()),
AgentID: update.AgentID,
CommandType: models.CommandTypeDryRunUpdate,
Params: params,
Status: models.CommandStatusPending,
Source: models.CommandSourceManual,
CreatedAt: time.Now().UTC(),
}
// Collapse the agent's poll interval during the active phase (best-effort).
if shouldEnable, err := h.shouldEnableHeartbeat(update.AgentID, 10); err == nil && shouldEnable {
heartbeatCmd := &models.AgentCommand{
ID: uuid.Must(uuid.NewV4()),
AgentID: update.AgentID,
CommandType: models.CommandTypeEnableHeartbeat,
Params: models.JSONB{"duration_minutes": 10},
Status: models.CommandStatusPending,
Source: models.CommandSourceSystem,
CreatedAt: time.Now().UTC(),
}
if err := h.agentHandler.signAndCreateCommand(heartbeatCmd); err != nil {
log.Printf("[WARNING] [server] [updates] heartbeat_create_failed agent_id=%s error=%v", update.AgentID, err)
}
}
if err := h.agentHandler.signAndCreateCommand(command); err != nil {
return fmt.Errorf("create dry run command: %w", err)
}
if err := h.updateQueries.SetCheckingDependencies(update.ID); err != nil {
return fmt.Errorf("set checking_dependencies: %w", err)
}
return nil
}
// shouldEnableHeartbeat checks if heartbeat is already active for an agent
// Returns true if heartbeat should be enabled (i.e., not already active or expired)
func (h *UpdateHandler) shouldEnableHeartbeat(agentID uuid.UUID, durationMinutes int) (bool, error) {
agent, err := h.agentQueries.GetAgentByID(agentID)
if err != nil {
log.Printf("Warning: Failed to get agent %s for heartbeat check: %v", agentID, err)
return true, nil // Enable heartbeat by default if we can't check
}
// Check if rapid polling is already enabled and not expired
if enabled, ok := agent.Metadata["rapid_polling_enabled"].(bool); ok && enabled {
if untilStr, ok := agent.Metadata["rapid_polling_until"].(string); ok {
until, err := time.Parse(time.RFC3339, untilStr)
if err == nil && until.After(time.Now().UTC().Add(5*time.Minute)) {
// Heartbeat is already active for sufficient time
log.Printf("[Heartbeat] Agent %s already has active heartbeat until %s (skipping)", agentID, untilStr)
return false, nil
}
}
}
return true, nil
}
// ReportUpdates handles update reports from agents using event sourcing
func (h *UpdateHandler) ReportUpdates(c *gin.Context) {
agentID := c.MustGet("agent_id").(uuid.UUID)
// Update last_seen timestamp
if err := h.agentQueries.UpdateAgentLastSeen(agentID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update last seen"})
return
}
var req models.UpdateReportRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Convert update report items to events
events := make([]models.UpdateEvent, 0, len(req.Updates))
for _, item := range req.Updates {
// Merge agent-reported top-level fields into metadata JSONB so they
// survive through to current_package_state for UI enrichment.
meta := item.Metadata
if item.PackageDescription != "" || len(item.CVEList) > 0 || item.KBID != "" || item.SizeBytes > 0 {
if meta == nil {
meta = make(models.JSONB)
}
if item.PackageDescription != "" {
meta["description"] = item.PackageDescription
}
if len(item.CVEList) > 0 {
meta["cve_list"] = item.CVEList
}
if item.KBID != "" {
meta["kb_id"] = item.KBID
}
if item.SizeBytes > 0 {
meta["size_bytes"] = item.SizeBytes
}
}
event := models.UpdateEvent{
ID: uuid.Must(uuid.NewV4()),
AgentID: agentID,
PackageType: item.PackageType,
PackageName: item.PackageName,
VersionFrom: item.CurrentVersion,
VersionTo: item.AvailableVersion,
Severity: item.Severity,
RepositorySource: item.RepositorySource,
Metadata: meta,
EventType: "discovered",
CreatedAt: req.Timestamp,
}
events = append(events, event)
}
// Store events in batch with error isolation
if err := h.updateQueries.CreateUpdateEventsBatch(events); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to record update events"})
return
}
// Best-effort: record each offered version in the timeline catalog so the package
// detail pane and as-of-date resolution have history to read. A failure here must
// not fail the scan report.
for _, item := range req.Updates {
pv := &models.PackageVersion{
PackageType: item.PackageType,
PackageName: item.PackageName,
Version: item.AvailableVersion,
}
if item.RepositorySource != "" {
src := item.RepositorySource
pv.Source = &src
}
if err := h.updateQueries.UpsertPackageVersion(pv); err != nil {
log.Printf("[WARNING] [server] [versions] catalog_upsert_failed pkg=%s version=%s error=%v",
item.PackageName, item.AvailableVersion, err)
}
}
// Best-effort OSV.dev check at discovery time — runs async so it never
// blocks the agent report. Deduped: same pkg+version is only queried once
// per server lifetime. Results (vulns or clean) are written back to the
// current_package_state metadata so the UI's "Known Vulnerabilities" card
// shows them before the operator approves.
h.bg("osv_checks", func() { enqueueOSVChecks(events, h.updateQueries) })
// Fire the orchestrator's auto-approve fast-path so packages eligible by
// policy advance without waiting for the next timer sweep. Non-blocking and
// best-effort — the timer sweep is the backstop.
if h.orchestrator != nil {
h.orchestrator.OnPackagesDiscovered()
}
// Fire the reconciler so newly-reported packages get automatic bindings.
if h.reconciler != nil {
h.bg("reconcile_all", func() { h.reconciler.ReconcileAll(context.Background()) })
}
// RECONCILE-001: scan-set closure (close-by-absence).
// Only fires when the agent confirmed a successful (exit 0), complete scan
// of a scoped ecosystem (dnf, apt). A failed/partial scan MUST NOT close rows
// — presence of the package in a failed scan means nothing about its current
// install state. Docker is explicitly excluded: image-set semantics differ
// from OS package-manager semantics and will be handled separately.
if req.ScanSucceeded && scanEcosystemSupported(req.Ecosystem) {
// Build the set of package names present in this scan (the reported set).
reportedSet := make(map[string]struct{}, len(req.Updates))
for _, item := range req.Updates {
reportedSet[item.PackageName] = struct{}{}
}
// Run closure async so it does not delay the HTTP response to the agent.
// Errors are logged; they do not fail the report (additive ingest already
// committed). The reconciler is not on the critical path.
h.bg("close_scan_absent", func() { h.closeScanAbsentRows(agentID, req.Ecosystem, reportedSet) })
}
c.JSON(http.StatusOK, gin.H{
"message": "update events recorded",
"count": len(events),
"command_id": req.CommandID,
})
}
// scanEcosystemSupported returns true for the OS package managers whose scan
// semantics guarantee a complete list of available updates on success.
// Docker is explicitly false: image sets have different update semantics
// (digest pinning vs. available-upgrade lists) and will be handled separately.
func scanEcosystemSupported(ecosystem string) bool {
switch ecosystem {
case "dnf", "apt", "pacman":
return true
default:
return false
}
}
// closeScanAbsentRows is the core of RECONCILE-001: scan-set closure.
// It computes waiting_rows(agent, ecosystem) reported_set = to_close,
// then transitions each absent row to installed via the state machine.
//
// Closure scope is the *waiting* states only (pending, approved) — enforced by
// GetTrackedNonResting. In-flight states (checking_dependencies, pending_dependencies,
// installing) are owned by the orchestrator + capability-receipt path and are left alone,
// so the reconciler never races a RedFlag-driven install nor mislabels its provenance.
// A waiting row is out-of-band by construction (RedFlag had not begun executing it):
// capability tokens are only minted after checking_dependencies, a state a waiting row
// has not reached, so it can hold no active token for its current version. Closures here
// are therefore always stamped out_of_band — see the provenance note below.
//
// Safety invariants (ETHOS §3):
// - Only called when req.ScanSucceeded is true (caller enforces).
// - Only for supported ecosystems (caller enforces via scanEcosystemSupported).
// - Each closure routes through transitionStatus (guarded UPDATE with WHERE
// status = $current), so concurrent racing callers land on the constraint,
// not a silent overwrite. pending/approved -> installed is a permitted edge.
// - Idempotent: running 3× produces the same result (ETHOS §4).
func (h *UpdateHandler) closeScanAbsentRows(agentID uuid.UUID, ecosystem string, reportedSet map[string]struct{}) {
// Load all non-resting rows for this (agent, ecosystem).
tracked, err := h.updateQueries.GetTrackedNonResting(agentID, ecosystem)
if err != nil {
log.Printf("[ERROR] [server] [reconcile] get_tracked_non_resting_failed agent=%s ecosystem=%s error=%v",
agentID, ecosystem, err)
return
}
if len(tracked) == 0 {
return
}
// Diff: tracked reported = to_close.
for _, row := range tracked {
if _, present := reportedSet[row.PackageName]; present {
// Still in scan — keep / version-bump handled by UpdateCurrentStateInTx.
continue
}
// Package absent from scan: this closure is out_of_band by construction.
// We deliberately do NOT consult consumed capability tokens here. The row ID
// is preserved across the UPSERT on (agent_id, package_type, package_name), so
// a consumed token from a *prior* lifecycle (when this row was last installed
// via the receipt path) stays linked to the same ID. A waiting-state row cannot
// have a token for its *current* version — tokens are minted only after
// checking_dependencies, which a waiting row has not reached — so any consumed
// token we'd find is stale and would mislabel a genuine out-of-band closure as
// redflag_receipt. Always stamp out_of_band.
meta := models.JSONB{
"resolution_provenance": "out_of_band",
"closed_by": "scan_set_reconciler",
"ecosystem": ecosystem,
"closed_at": time.Now().UTC().Format(time.RFC3339),
}
if err := h.updateQueries.TransitionByID(row.ID, models.StatusInstalled, meta); err != nil {
// A concurrent state-machine move (operator approved, installed, etc.)
// may have already advanced the row — that is not an error here.
log.Printf("[INFO] [server] [reconcile] close_skipped update_id=%s package=%s/%s ecosystem=%s error=%v",
row.ID, row.PackageType, row.PackageName, ecosystem, err)
continue
}
log.Printf("[INFO] [server] [reconcile] closed_by_absence update_id=%s package=%s/%s ecosystem=%s provenance=out_of_band",
row.ID, row.PackageType, row.PackageName, ecosystem)
// Emit a system event per closure for auditability (ETHOS §1, history table).
// Best-effort: a failure here must not rollback the transition already committed.
if h.agentQueries != nil {
ev := &models.SystemEvent{
ID: uuid.Must(uuid.NewV4()),
AgentID: &agentID,
EventType: models.EventTypeAgentUpdate,
EventSubtype: models.SubtypeSuccess,
Severity: models.SeverityInfo,
Component: models.ComponentServer,
Message: fmt.Sprintf(
"scan-set closure: %s/%s absent from %s scan, transitioned to installed (provenance: out_of_band)",
row.PackageType, row.PackageName, ecosystem,
),
Metadata: meta,
CreatedAt: time.Now().UTC(),
}
if err := h.agentQueries.CreateSystemEvent(ev); err != nil {
log.Printf("[WARNING] [server] [reconcile] system_event_failed update_id=%s error=%v",
row.ID, err)
}
}
}
}
// enqueueOSVChecks checks each unique package+version in the batch against
// OSV.dev with bounded concurrency, persisting every outcome (clean, vuln, or
// recorded failure) to current_package_state.metadata. Packages whose stored
// result is still fresh (per OSVRecheckInterval) are skipped via the persisted
// timestamp — not a process-local cache — so the dedup survives restart and a
// failed check is retried next cycle.
func enqueueOSVChecks(events []models.UpdateEvent, q *queries.UpdateQueries) {
type dedupKey struct {
agentID uuid.UUID
pkgType, pkgName, version string
}
seen := make(map[dedupKey]bool)
// freshByAgent caches freshness results per agent per namespace to avoid
// repeated DB queries within one batch.
type agentNS struct {
id uuid.UUID
namespace string
}
freshByAgent := make(map[agentNS]map[string]bool)
getFresh := func(agentID uuid.UUID, ns string) map[string]bool {
key := agentNS{agentID, ns}
if m, ok := freshByAgent[key]; ok {
return m
}
f, err := q.FreshSupplyChainPackages(agentID, services.OSVRecheckInterval, ns)
if err != nil {
log.Printf("[WARNING] [supply_chain] fresh_query_failed agent=%s namespace=%s error=%v", agentID, ns, err)
f = map[string]bool{}
}
freshByAgent[key] = f
return f
}
var reqs []services.OSVCheckRequest
for _, e := range events {
if !services.NeedsSupplyChainCheck(e.PackageType) {
continue
}
// Remediation check — available version.
k := dedupKey{e.AgentID, e.PackageType, e.PackageName, e.VersionTo}
if !seen[k] {
seen[k] = true
if !getFresh(e.AgentID, "")[osvFreshKey(e.PackageType, e.PackageName, e.VersionTo)] {
reqs = append(reqs, services.OSVCheckRequest{
AgentID: e.AgentID,
PkgType: e.PackageType,
PkgName: e.PackageName,
Version: e.VersionTo,
})
}
}
// Threat check — installed version. Skip if blank or same as available
// (no new information) or already fresh.
if e.VersionFrom == "" || e.VersionFrom == e.VersionTo {
continue
}
ki := dedupKey{e.AgentID, e.PackageType, e.PackageName, e.VersionFrom}
if seen[ki] {
continue
}
seen[ki] = true
if !getFresh(e.AgentID, "installed")[osvFreshKey(e.PackageType, e.PackageName, e.VersionFrom)] {
reqs = append(reqs, services.OSVCheckRequest{
AgentID: e.AgentID,
PkgType: e.PackageType,
PkgName: e.PackageName,
Version: e.VersionFrom,
Namespace: "installed",
})
}
}
if len(reqs) == 0 {
return
}
services.RunOSVChecks(reqs, func(agentID uuid.UUID, pkgType, pkgName string, meta map[string]interface{}) error {
return storeOSVCheckResult(q, agentID, pkgType, pkgName, meta)
})
}
func osvFreshKey(pkgType, pkgName, version string) string {
return pkgType + "\x00" + pkgName + "\x00" + version
}
func storeOSVCheckResult(q *queries.UpdateQueries, agentID uuid.UUID, pkgType, pkgName string, meta map[string]interface{}) error {
if err := q.StoreSupplyChainMetadata(agentID, pkgType, pkgName, models.JSONB(meta)); err != nil {
return err
}
if pv := packageVersionFromOSVMeta(pkgType, pkgName, meta); pv != nil {
if err := q.UpsertPackageVersion(pv); err != nil {
log.Printf("[WARNING] [server] [versions] osv_catalog_failed pkg=%s version=%s error=%v", pkgName, pv.Version, err)
}
}
return nil
}
func packageVersionFromOSVMeta(pkgType, pkgName string, meta map[string]interface{}) *models.PackageVersion {
version, vulnsKey := stringMeta(meta["supply_chain_checked_version"]), "supply_chain_vulns"
if version == "" {
version, vulnsKey = stringMeta(meta["installed_checked_version"]), "installed_vulns"
}
if version == "" {
return nil
}
status := "clean"
var rawVulns *string
if raw := vulnMetaString(meta[vulnsKey]); raw != "" {
status = "vulnerable"
rawVulns = &raw
}
return &models.PackageVersion{
PackageType: pkgType,
PackageName: pkgName,
Version: version,
OSVStatus: &status,
OSVVulns: rawVulns,
}
}
func stringMeta(v interface{}) string {
if s, ok := v.(string); ok {
return strings.TrimSpace(s)
}
return ""
}
func vulnMetaString(v interface{}) string {
switch t := v.(type) {
case nil:
return ""
case string:
s := strings.TrimSpace(t)
if s == "" || s == "[]" || s == "null" {
return ""
}
return s
default:
raw, err := json.Marshal(t)
if err != nil {
return ""
}
s := strings.TrimSpace(string(raw))
if s == "" || s == "[]" || s == "null" {
return ""
}
return s
}
}
// ListUpdates retrieves updates with filtering using the new state table
func (h *UpdateHandler) ListUpdates(c *gin.Context) {
// Parse and validate optional status filter
var statusFilter models.PackageStatus
if s := c.Query("status"); s != "" {
if parsed, err := models.StatusFromString(s); err == nil {
statusFilter = parsed
}
}
filters := &models.UpdateFilters{
Status: statusFilter,
Severity: c.Query("severity"),
PackageType: c.Query("package_type"),
}
// Parse agent_id if provided
if agentIDStr := c.Query("agent_id"); agentIDStr != "" {
agentID, err := uuid.FromString(agentIDStr)
if err == nil {
filters.AgentID = agentID
}
}
// Parse pagination
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "50"))
filters.Page = page
filters.PageSize = pageSize
updates, total, err := h.updateQueries.ListUpdatesFromState(filters)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list updates"})
return
}
// Get overall statistics for the summary cards
stats, err := h.updateQueries.GetAllUpdateStats()
if err != nil {
// Don't fail the request if stats fail, just log and continue
// In production, we'd use proper logging
stats = &models.UpdateStats{}
}
c.JSON(http.StatusOK, gin.H{
"updates": updates,
"total": total,
"page": page,
"page_size": pageSize,
"stats": stats,
})
}
// severityLabelForRank maps the SQL severity rank back to a label for the UI.
func severityLabelForRank(rank int) string {
switch rank {
case 4:
return "critical"
case 3:
return "high"
case 2:
return "medium"
case 1:
return "low"
default:
return "unknown"
}
}
// ListPackages returns the fleet rolled up by package (one row per package_type +
// package_name across all agents) for the package-centric Updates view. Each row
// carries agent/version counts, a max-severity label, vuln + hash-pin rollups, and a
// representative update id for drill-in. Shares the overall stat cards with ListUpdates.
func (h *UpdateHandler) ListPackages(c *gin.Context) {
search := c.Query("search")
packageType := c.Query("type")
if packageType == "" {
packageType = c.Query("package_type")
}
status := c.Query("status")
sortBy := c.Query("sort_by")
sortOrder := c.Query("sort_order")
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "100"))
rows, total, err := h.updateQueries.ListAggregatedPackages(search, packageType, status, sortBy, sortOrder, page, pageSize)
if err != nil {
log.Printf("[ERROR] [server] [updates] list_packages_failed error=%v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list packages"})
return
}
type packageRow struct {
queries.AggregatedPackage
MaxSeverity string `json:"max_severity"`
}
out := make([]packageRow, 0, len(rows))
for _, r := range rows {
out = append(out, packageRow{AggregatedPackage: r, MaxSeverity: severityLabelForRank(r.MaxSeverityRank)})
}
stats, err := h.updateQueries.GetAllUpdateStats()
if err != nil {
stats = &models.UpdateStats{}
}
c.JSON(http.StatusOK, gin.H{
"packages": out,
"total": total,
"page": page,
"page_size": pageSize,
"stats": stats,
})
}
// GetUpdate retrieves a single update by ID
func (h *UpdateHandler) GetUpdate(c *gin.Context) {
idStr := c.Param("id")
id, err := uuid.FromString(idStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid update ID"})
return
}
update, err := h.updateQueries.GetUpdateByID(id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "update not found"})
return
}
update.EnrichFromMetadata()
c.JSON(http.StatusOK, update)
}
// GetPackageFleet returns every agent affected by a package (same name+type),
// for the package detail pane's "affected agents" view. Identified by an update
// id so the client can pivot from one row to the whole fleet without already
// knowing the package coordinates.
func (h *UpdateHandler) GetPackageFleet(c *gin.Context) {
idStr := c.Param("id")
id, err := uuid.FromString(idStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid update ID"})
return
}
update, err := h.updateQueries.GetUpdateByID(id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "update not found"})
return
}
fleet, err := h.updateQueries.GetPackageFleet(update.PackageType, update.PackageName)
if err != nil {
log.Printf("[ERROR] [server] [updates] package_fleet_query_failed id=%s pkg=%s error=%v",
id, update.PackageName, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load package fleet"})
return
}
c.JSON(http.StatusOK, gin.H{
"package_name": update.PackageName,
"package_type": update.PackageType,
"agents": fleet,
})
}
// GetPackageVersions returns the version timeline catalog for a package, identified by
// an update id, for the detail pane's Version Timeline card.
func (h *UpdateHandler) GetPackageVersions(c *gin.Context) {
idStr := c.Param("id")
id, err := uuid.FromString(idStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid update ID"})
return
}
update, err := h.updateQueries.GetUpdateByID(id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "update not found"})
return
}
versions, err := h.updateQueries.GetPackageVersions(update.PackageType, update.PackageName)
if err != nil {
log.Printf("[ERROR] [server] [versions] timeline_query_failed id=%s pkg=%s error=%v",
id, update.PackageName, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load version timeline"})
return
}
c.JSON(http.StatusOK, gin.H{
"package_name": update.PackageName,
"package_type": update.PackageType,
"versions": versions,
})
}
// GetPackageSummaryByCoords returns the package-centric aggregate view for
// GET /updates/package/:type/:name — metadata, fleet status counts, and a
// deduplicated vulnerability list, without being tied to a single agent row.
func (h *UpdateHandler) GetPackageSummaryByCoords(c *gin.Context) {
pkgType := c.Param("type")
pkgName := c.Param("name")
if pkgType == "" || pkgName == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "package type and name are required"})
return
}
summary, err := h.updateQueries.GetPackageSummary(pkgType, pkgName)
if err != nil {
log.Printf("[ERROR] [server] [updates] package_summary_failed type=%s name=%s error=%v",
pkgType, pkgName, err)
c.JSON(http.StatusNotFound, gin.H{"error": "package not found"})
return
}
c.JSON(http.StatusOK, summary)
}
// GetPackageAgentsByCoords returns per-agent rows for
// GET /updates/package/:type/:name/agents — each agent's version, status, and
// actionable flags (can_approve, can_install, can_retry).
func (h *UpdateHandler) GetPackageAgentsByCoords(c *gin.Context) {
pkgType := c.Param("type")
pkgName := c.Param("name")
if pkgType == "" || pkgName == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "package type and name are required"})
return
}
fleet, err := h.updateQueries.GetPackageFleet(pkgType, pkgName)
if err != nil {
log.Printf("[ERROR] [server] [updates] package_agents_failed type=%s name=%s error=%v",
pkgType, pkgName, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load package agents"})
return
}
c.JSON(http.StatusOK, gin.H{
"package_type": pkgType,
"package_name": pkgName,
"agents": fleet,
})
}
// GetPackageVersionsByCoords returns the version timeline for
// GET /updates/package/:type/:name/versions.
func (h *UpdateHandler) GetPackageVersionsByCoords(c *gin.Context) {
pkgType := c.Param("type")
pkgName := c.Param("name")
if pkgType == "" || pkgName == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "package type and name are required"})
return
}
versions, err := h.updateQueries.GetPackageVersions(pkgType, pkgName)
if err != nil {
log.Printf("[ERROR] [server] [versions] package_versions_failed type=%s name=%s error=%v",
pkgType, pkgName, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load version timeline"})
return
}
c.JSON(http.StatusOK, gin.H{
"package_type": pkgType,
"package_name": pkgName,
"versions": versions,
})
}
// GetPackageVulnerabilitiesByCoords returns the deduplicated vulnerability list for
// GET /updates/package/:type/:name/vulnerabilities — merged from package_versions
// osv_vulns and live agent supply_chain_vulns metadata.
func (h *UpdateHandler) GetPackageVulnerabilitiesByCoords(c *gin.Context) {
pkgType := c.Param("type")
pkgName := c.Param("name")
if pkgType == "" || pkgName == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "package type and name are required"})
return
}
vulns, err := h.updateQueries.GetPackageVulnerabilitiesByTypeAndName(pkgType, pkgName)
if err != nil {
log.Printf("[ERROR] [server] [updates] package_vulns_failed type=%s name=%s error=%v",
pkgType, pkgName, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load vulnerabilities"})
return
}
c.JSON(http.StatusOK, gin.H{
"package_type": pkgType,
"package_name": pkgName,
"vulnerabilities": vulns,
})
}
// supplyChainHold is the result of the manual-approval supply-chain gate: whether
// approval must stop, whether the cause was an unverifiable closure (vs. a known
// vuln), a human-readable reason, and the top-level vulnerabilities to echo back.
type supplyChainHold struct {
blocked bool
unverified bool // closure OSV could not run — "trust the void", not a known vuln
reason string
vulns []services.VulnerabilityInfo
}
const (
selectedVersionSourceKey = "selected_version_source"
selectedVersionSourceManual = "manual"
selectedVersionSourceInstalledHold = "installed_hold"
)
// resolvePackageAgeGateConfig returns the approval-time package-age gate policy.
// It prefers the live settings service (env > config > DB > default, so a UI/DB
// override actually drives the gate) and falls back to the env-only
// PackageAgeGateConfig when no settings service is wired.
func (h *UpdateHandler) resolvePackageAgeGateConfig() (minAgeHours float64, enforcement string, blockUnknownAge bool) {
if h.securitySettings != nil {
return h.securitySettings.GetSupplyChainGateConfig()
}
minAgeHours, enforcement = services.PackageAgeGateConfig()
return
}
// resolveSoakGateConfig returns the install-path version-soak gate policy
// (GATE-005), preferring the live settings service (env > config > DB > default,
// so an admin policy drives the gate) and falling back to env-only SoakGateConfig
// when no settings service is wired.
func (h *UpdateHandler) resolveSoakGateConfig() (requiredDays float64, enforcement string) {
if h.securitySettings != nil {
return h.securitySettings.GetSoakGateConfig()
}
return services.SoakGateConfig()
}
// resolveGatedTarget returns RedFlag's *automatic* gated install target for an update, or
// "" to fall back to the newest available version. [GATE-006 B]
//
// This is RedFlag pinning a version on the operator's behalf — distinct from a
// manual force-pin, which is recorded as selected_version_source=manual and is
// honored ahead of this. Installed-version holds use the same selected_version
// value column but source=installed_hold, so they do not block future soak-gated
// auto-targeting.
//
// Two guards keep the auto-pin safe:
// - It runs only when the soak gate is actually enforced ("block"). In "warn"/"off" the
// install target is left unchanged — we don't silently re-aim installs the admin
// hasn't committed to gating.
// - Forward-only where RedFlag has a reliable comparator. DNF/APT versions are
// package-manager-native strings, so the server only filters exact no-ops for
// those ecosystems and leaves installability to the agent dry-run/hash gate.
func (h *UpdateHandler) resolveGatedTarget(update *models.UpdateState) string {
if h.updateQueries == nil {
return ""
}
requiredDays, enforcement := h.resolveSoakGateConfig()
if requiredDays <= 0 || enforcement != "block" {
return ""
}
pv, err := h.updateQueries.GetGatedVersion(update.PackageType, update.PackageName, requiredDays)
if err != nil {
log.Printf("[WARNING] [server] [soak_gate] gated_lookup_failed pkg=%s error=%v", update.PackageName, err)
return ""
}
if pv == nil {
return "" // nothing has aged past the soak window yet — don't pull anything newer
}
if !gatedTargetAhead(update.PackageType, pv.Version, update.CurrentVersion) {
return "" // the gated version is empty or an exact no-op
}
log.Printf("[INFO] [server] [soak_gate] gated_target pkg=%s gated=%s current=%s available=%s soak_days=%.1f",
update.PackageName, pv.Version, update.CurrentVersion, update.AvailableVersion, requiredDays)
return pv.Version
}
func gatedTargetAhead(packageType, targetVersion, currentVersion string) bool {
target := strings.TrimSpace(targetVersion)
current := strings.TrimSpace(currentVersion)
if target == "" {
return false
}
if current == "" {
return true
}
switch strings.ToLower(strings.TrimSpace(packageType)) {
case "apt":
// dpkg ordering is not semver, and Debian release suffixes such as
// 1ubuntu1~22.04 are easy to mis-sort without dpkg --compare-versions.
// Exact equality is the only portable no-op guard here. Dry-run and exact
// hash binding decide installability.
return target != current
case "dnf", "pacman":
// pacman's vercmp is derived from rpm's, and Arch's epoch:pkgver-pkgrel
// format maps cleanly to RPM's epoch:version-release. The same comparison
// logic applies to both.
return rpmEVRAhead(target, current)
default:
return utils.CompareVersions(target, current) > 0
}
}
func rpmEVRAhead(target, current string) bool {
if target == current {
return false
}
targetEpoch, targetVersion, targetRelease := splitRPMEVR(target)
currentEpoch, currentVersion, currentRelease := splitRPMEVR(current)
if targetEpoch != currentEpoch {
return targetEpoch > currentEpoch
}
if cmp := rpmVersionCompare(targetVersion, currentVersion); cmp != 0 {
return cmp > 0
}
if cmp := rpmVersionCompare(targetRelease, currentRelease); cmp != 0 {
return cmp > 0
}
// All components equal — target is not ahead of current.
// This catches textually-different but semantically-equal strings such as
// "0:2.0-1" vs "2.0-1" where splitRPMEVR normalises the missing epoch to 0.
return false
}
func splitRPMEVR(evr string) (epoch int, version string, release string) {
version = strings.TrimSpace(evr)
if before, after, ok := strings.Cut(version, ":"); ok {
if parsed, err := strconv.Atoi(before); err == nil {
epoch = parsed
version = after
}
}
if before, after, ok := strings.Cut(version, "-"); ok {
version = before
release = after
}
return epoch, version, release
}
func rpmVersionCompare(a, b string) int {
for a != "" || b != "" {
a = trimRPMSeparators(a)
b = trimRPMSeparators(b)
if a == "" || b == "" {
return compareRPMEmpty(a, b)
}
aDigit := isASCIIDigit(a[0])
bDigit := isASCIIDigit(b[0])
if aDigit != bDigit {
if aDigit {
return 1
}
return -1
}
var aSeg, bSeg string
aSeg, a = nextRPMSegment(a, aDigit)
bSeg, b = nextRPMSegment(b, bDigit)
if aDigit {
aSeg = strings.TrimLeft(aSeg, "0")
bSeg = strings.TrimLeft(bSeg, "0")
if len(aSeg) != len(bSeg) {
if len(aSeg) > len(bSeg) {
return 1
}
return -1
}
}
if aSeg != bSeg {
if aSeg > bSeg {
return 1
}
return -1
}
}
return 0
}
func trimRPMSeparators(s string) string {
return strings.TrimLeftFunc(s, func(r rune) bool {
return !((r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9'))
})
}
func compareRPMEmpty(a, b string) int {
if a == b {
return 0
}
if a != "" {
return 1
}
return -1
}
func nextRPMSegment(s string, digit bool) (segment string, rest string) {
for i := 0; i < len(s); i++ {
if isASCIIDigit(s[i]) != digit || !isASCIIAlnum(s[i]) {
return s[:i], s[i:]
}
}
return s, ""
}
func isASCIIDigit(b byte) bool {
return b >= '0' && b <= '9'
}
func isASCIIAlnum(b byte) bool {
return isASCIIDigit(b) || (b >= 'A' && b <= 'Z') || (b >= 'a' && b <= 'z')
}
func (h *UpdateHandler) resolveInstallTarget(update *models.UpdateState) (version string, explicit bool) {
if update.SelectedVersion != nil {
selected := strings.TrimSpace(*update.SelectedVersion)
if selected != "" {
source := selectedVersionSource(update)
if source == selectedVersionSourceInstalledHold || legacySelectedVersionLooksLikeInstalledHold(update, selected) {
if gated := h.resolveGatedTarget(update); gated != "" {
return gated, true
}
// Fall through to return selected below
}
// Not an installed hold (or hold with no gated target) — use selected version.
return selected, true
}
}
if gated := h.resolveGatedTarget(update); gated != "" {
return gated, true
}
return update.AvailableVersion, false
}
func selectedVersionSource(update *models.UpdateState) string {
if update.Metadata == nil {
return ""
}
if s, ok := update.Metadata[selectedVersionSourceKey].(string); ok {
return strings.TrimSpace(s)
}
return ""
}
func legacySelectedVersionLooksLikeInstalledHold(update *models.UpdateState, selected string) bool {
if selectedVersionSource(update) != "" {
return false
}
return (update.Status == models.StatusInstalled || update.Status == models.StatusPending) &&
update.AvailableVersion != "" &&
selected != update.AvailableVersion
}
// persistedSupplyChainVulns extracts the OSV vulnerabilities recorded on the
// update at *detection* time. OSV scanning runs on the scan-report path
// (enqueueOSVChecks), not at approval — the approval path only reads the verdict
// detection persisted into supply_chain_vulns metadata. Returns nil when none
// were recorded (clean, not-yet-checked, or non-supply-chain ecosystem).
func persistedSupplyChainVulns(update *models.UpdateState, targetVersion string) []services.VulnerabilityInfo {
if update.Metadata == nil {
return nil
}
checkedVersion, ok := update.Metadata["supply_chain_checked_version"].(string)
if !ok || checkedVersion == "" || targetVersion == "" || checkedVersion != targetVersion {
return nil
}
raw, ok := update.Metadata["supply_chain_vulns"].(string)
if !ok || strings.TrimSpace(raw) == "" {
return nil
}
var vulns []services.VulnerabilityInfo
if err := json.Unmarshal([]byte(raw), &vulns); err != nil {
log.Printf("[WARNING] [server] [supply_chain] persisted_vulns_unmarshal_failed id=%s error=%v", update.ID, err)
return nil
}
return vulns
}
func metadataHasTargetSupplyChainVulns(update *models.UpdateState, targetVersion string) bool {
if update.Metadata == nil || targetVersion == "" {
return false
}
checkedVersion, ok := update.Metadata["supply_chain_checked_version"].(string)
if !ok || checkedVersion == "" || checkedVersion != targetVersion {
return false
}
return models.MetadataHasVulns(*update, "supply_chain_vulns")
}
// recordPackageAgeMetadata stamps the supply-chain age-gate verdict onto the
// update's metadata for auditability. A known publish date records the timestamp
// and age; a fail-closed-on-unknown block records the unknown marker so an
// operator can see why the install was held. A plain fail-open unknown (allowed)
// records nothing — there is no verdict worth persisting.
func recordPackageAgeMetadata(update *models.UpdateState, dec services.PackageAgeGateDecision) {
if dec.Unknown && !dec.ShouldBlock {
return
}
if update.Metadata == nil {
update.Metadata = make(models.JSONB)
}
check := map[string]interface{}{
"min_age_hours": dec.MinAgeHours,
"enforcement": dec.Enforcement,
"blocked": dec.ShouldBlock,
"unknown": dec.Unknown,
}
if dec.Unknown {
check["blocked_unknown"] = dec.BlockedUnknown
} else {
update.Metadata["package_published_at"] = dec.PublishedAt.UTC().Format(time.RFC3339)
update.Metadata["package_age_hours"] = dec.AgeHours
check["source"] = dec.Source
}
update.Metadata["supply_chain_age_check"] = check
}
// evaluateSupplyChainHold decides whether a manual approval must full-stop before
// minting. A known vulnerability anywhere we have looked — the top-level package
// (this call or persisted) or any artifact in the resolved closure — blocks. For
// closure-gated ecosystems, a closure that WAS checked but could not be verified
// (OSV unreachable) also blocks: minting over it would trust artifacts nothing
// vetted. Packages whose closure has not been checked at all (no dry-run yet)
// pass through here — the gate is enforced later at dependency-confirmation time
// by the orchestrator's sweepAutoConfirm, which only advances a closure that was
// OSV-checked AND clean. Mirrors the auto-confirm gate (orchestrator.closureCleared)
// via the shared models predicates.
func (h *UpdateHandler) evaluateSupplyChainHold(update *models.UpdateState, targetVersion string, freshVulns []services.VulnerabilityInfo) supplyChainHold {
// GATE-006 D: check the version row's osv_status for the target version
// (the version that will actually install), not just the metadata side-channel.
// This closes the hole where we OSV-check one version but install another.
if targetVersion != "" {
if osvStatus, osvVulns, err := h.updateQueries.GetVersionOSVStatus(update.PackageType, update.PackageName, targetVersion); err == nil && osvStatus == "vulnerable" {
var vulns []services.VulnerabilityInfo
if len(osvVulns) > 0 {
if err := json.Unmarshal(osvVulns, &vulns); err != nil {
log.Printf("[ERROR] [server] [updates] osv_vulns_unmarshal_failed pkg=%s version=%s error=%v", update.PackageName, targetVersion, err)
// vulns stays nil; block still fires — fail closed on unmarshal error
}
}
return supplyChainHold{blocked: true, reason: fmt.Sprintf("target version %s has known vulnerabilities (version row)", targetVersion), vulns: vulns}
}
}
// Fallback: metadata side-channel (detection-time verdict). Still needed for
// packages not yet in the version catalog or where the version row has no
// osv_status yet.
if len(freshVulns) > 0 || metadataHasTargetSupplyChainVulns(update, targetVersion) {
return supplyChainHold{blocked: true, reason: "known vulnerabilities in the package", vulns: freshVulns}
}
if models.MetadataHasVulns(*update, "closure_vulns") {
return supplyChainHold{blocked: true, reason: "known vulnerabilities in a dependency in the resolved closure", vulns: freshVulns}
}
if services.NeedsCapabilityGate(update.PackageType) && models.ClosureChecked(*update) && !models.ClosureCleared(*update) {
return supplyChainHold{
blocked: true,
unverified: true,
reason: "dependency closure could not be checked against OSV (service unreachable) — installing trusts unverified artifacts",
vulns: freshVulns,
}
}
return supplyChainHold{}
}
// recordGateOverride journals an operator override of any supply-chain gate.
// ETHOS #1: the override is on the record; the signed token still binds real hashes.
func (h *UpdateHandler) recordGateOverride(update *models.UpdateState, eventType, subtype, logTag, message string, meta map[string]interface{}) {
log.Printf("[SECURITY] [server] [%s] gate_overridden id=%s pkg=%s", logTag, update.ID, update.PackageName)
event := &models.SystemEvent{
ID: uuid.Must(uuid.NewV4()),
AgentID: &update.AgentID,
EventType: eventType,
EventSubtype: subtype,
Severity: models.SeverityWarning,
Component: models.ComponentSecurity,
Message: message,
Metadata: meta,
CreatedAt: time.Now().UTC(),
}
if err := h.agentQueries.CreateSystemEvent(event); err != nil {
log.Printf("[WARNING] [server] [%s] override_event_write_failed id=%s error=%v", logTag, update.ID, err)
}
}
// recordSupplyChainOverride journals an operator's deliberate decision to install
// past the supply-chain gate.
func (h *UpdateHandler) recordSupplyChainOverride(update *models.UpdateState, targetVersion string, hold supplyChainHold, operatorReason string) {
subtype := "vuln_overridden"
if hold.unverified {
subtype = "unverified_overridden"
}
h.recordGateOverride(update, "supply_chain_override", subtype, "supply_chain",
fmt.Sprintf("Operator overrode the supply-chain gate for %s %s and approved install anyway: %s",
update.PackageName, targetVersion, hold.reason),
map[string]interface{}{
"package_name": update.PackageName,
"package_type": update.PackageType,
"version": targetVersion,
"cause": hold.reason,
"unverified": hold.unverified,
"operator_reason": operatorReason,
"approved_by": "admin",
})
}
// ApproveUpdate marks an update as approved, with OSV.dev supply chain check
func (h *UpdateHandler) ApproveUpdate(c *gin.Context) {
idStr := c.Param("id")
id, err := uuid.FromString(idStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid update ID"})
return
}
// Optional override body. Absent body = no override, the default and safe path.
var req struct {
OverrideSupplyChain bool `json:"override_supply_chain"`
OverrideReason string `json:"override_reason"`
}
_ = c.ShouldBindJSON(&req)
// Look up update details for supply chain check
update, err := h.updateQueries.GetUpdateByID(id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get update details"})
return
}
targetVersion, _ := h.resolveInstallTarget(update)
// OSV vulnerability data is produced at *detection* (enqueueOSVChecks on the
// scan-report path) and persisted to supply_chain_vulns metadata. Approval
// reads that persisted verdict — it does not re-scan OSV here.
vulns := persistedSupplyChainVulns(update, targetVersion)
var ageDecision services.PackageAgeGateDecision
if services.NeedsSupplyChainCheck(update.PackageType) {
ecosystem := services.EcosystemFromPackageType(update.PackageType)
// Time-delayed update gate (Shai-Hulud defense). Independent of OSV;
// runs even when there are no known CVEs because freshness itself is
// the signal — worm waves propagate inside the first-24h window.
minAgeHours, enforcement, blockUnknownAge := h.resolvePackageAgeGateConfig()
if enforcement != "off" {
age := services.GetPackagePublishDate(update.PackageName, ecosystem, targetVersion)
ageDecision = services.EvaluatePackageAgeGate(age, services.PackageAgeGatePolicy{
MinAgeHours: minAgeHours,
Enforcement: enforcement,
BlockUnknownAge: blockUnknownAge,
EcosystemAged: services.EcosystemSupportsPackageAge(ecosystem),
})
recordPackageAgeMetadata(update, ageDecision)
if ageDecision.ShouldBlock {
log.Printf("[WARNING] [supply_chain] approval_blocked_by_age_gate id=%s pkg=%s age_hours=%.2f min=%.2f unknown=%t",
id, update.PackageName, ageDecision.AgeHours, ageDecision.MinAgeHours, ageDecision.BlockedUnknown)
resp := gin.H{
"error": "approval blocked by supply chain age gate",
"reason": ageDecision.WarnMessage,
"package": update.PackageName,
"version": targetVersion,
"min_age_hours": ageDecision.MinAgeHours,
"override_hint": "set REDFLAG_SUPPLY_CHAIN_GATE_ENFORCEMENT=warn (or off) and retry; or wait until threshold passes",
}
if !ageDecision.BlockedUnknown {
resp["published_at"] = ageDecision.PublishedAt.UTC().Format(time.RFC3339)
resp["age_hours"] = ageDecision.AgeHours
}
c.JSON(http.StatusConflict, resp)
return
}
}
}
// Supply-chain full stop. A known vulnerability — top-level or anywhere in the
// resolved dependency closure — or a closure OSV could not verify, must not
// silently approve and mint a capability token. This is the hole the old
// "sovereignty principle" left open: it let vulns through with only a warning.
// Now it is a hard stop; only an explicit admin override ("I need this version
// anyway") proceeds, and that decision is journaled to system_events. The override
// waives the VULNERABILITY judgment only — it never touches the token's signature
// or artifact-hash verification, which remain absolute (ETHOS: no skip-verify path).
hold := h.evaluateSupplyChainHold(update, targetVersion, vulns)
if hold.blocked {
if !req.OverrideSupplyChain {
log.Printf("[SECURITY] [server] [supply_chain] approval_blocked id=%s pkg=%s reason=%q unverified=%t",
id, update.PackageName, hold.reason, hold.unverified)
c.JSON(http.StatusConflict, gin.H{
"error": "approval blocked by supply chain gate",
"reason": hold.reason,
"package": update.PackageName,
"version": targetVersion,
"unverified": hold.unverified,
"vulnerabilities": vulns,
"override_hint": "resubmit with override_supply_chain=true and a non-empty override_reason to install anyway",
})
return
}
if strings.TrimSpace(req.OverrideReason) == "" {
c.JSON(http.StatusBadRequest, gin.H{
"error": "supply chain override requires a reason",
"reason": hold.reason,
})
return
}
h.recordSupplyChainOverride(update, targetVersion, hold, req.OverrideReason)
}
// Layer 1: Hash Registry — compute and store expected_sha256 BEFORE writing the
// approval. The hash registry is the tamper-detection gate; an approval whose
// hash step errored must not stand (fail closed, ETHOS §2).
// Empty hash with no error is legitimate: agent-sourced ecosystems (dnf/apt)
// report their closure hashes via ReportDependencies, so computeAndStorePackageHash
// returns ("", nil) for those — approval proceeds normally.
artifactHash, err := h.computeAndStorePackageHash(update, targetVersion)
if err != nil {
log.Printf("[ERROR] [server] [updates] hash_computation_failed id=%s pkg=%s error=%v",
id, update.PackageName, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("hash computation failed; approval not written: %v", err)})
return
}
if artifactHash != "" {
log.Printf("[INFO] [server] [updates] hash_stored id=%s pkg=%s sha256=%s",
id, update.PackageName, artifactHash[:16]+"...")
}
// Proceed with approval (sovereignty principle: vulns + warn-mode age findings
// don't block; only "block" enforcement of the age gate does, handled above).
if len(vulns) > 0 || (!ageDecision.Unknown && ageDecision.WarnMessage != "") {
if err := h.updateQueries.ApproveUpdateWithVulns(id, "admin", update.Metadata); err != nil {
log.Printf("[ERROR] [server] [updates] approve_update_failed id=%s error=%v", id, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to approve update: %v", err)})
return
}
} else {
if err := h.updateQueries.ApproveUpdate(id, "admin"); err != nil {
log.Printf("[ERROR] [server] [updates] approve_update_failed id=%s error=%v", id, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to approve update: %v", err)})
return
}
}
// Enrich the version timeline with what this approval learned — OSV posture,
// publish date, artifact hash — so the detail pane and as-of-date resolution see
// it. Best-effort; never blocks an approval that already cleared the gates.
{
pv := &models.PackageVersion{
PackageType: update.PackageType,
PackageName: update.PackageName,
Version: targetVersion,
}
if artifactHash != "" {
pv.SHA256 = &artifactHash
}
if services.NeedsSupplyChainCheck(update.PackageType) {
status := "clean"
if len(vulns) > 0 {
status = "vulnerable"
}
pv.OSVStatus = &status
if raw, ok := update.Metadata["supply_chain_vulns"].(string); ok && raw != "" {
pv.OSVVulns = &raw
}
}
if !ageDecision.Unknown {
published := ageDecision.PublishedAt.UTC()
pv.PublishedAt = &published
}
if err := h.updateQueries.UpsertPackageVersion(pv); err != nil {
log.Printf("[WARNING] [server] [versions] approve_enrich_failed id=%s pkg=%s error=%v",
id, update.PackageName, err)
}
}
// Supply Chain Gate — mint a signed capability token over the approved
// closure. Single-entry closure today (top-level package + its expected
// hash); transitive resolution lands here later. Best-effort: a minting
// failure is logged at SECURITY but does not roll back an approval that has
// already cleared OSV/age/hash.
if h.minter.Enabled() {
if artifactHash == "" {
// Agent-sourced ecosystems (dnf/apt): the closure was
// already stored by pinReportedClosure during dry-run.
if _, err := h.mintResolvedClosure(update); err != nil {
log.Printf("[SECURITY] [server] [capability] mint_failed id=%s pkg=%s error=%v",
id, update.PackageName, err)
}
} else {
closure := []capability.ClosureEntry{{
Name: update.PackageName,
Version: targetVersion,
SHA256: artifactHash,
Source: "registry",
}}
if _, err := h.minter.MintForUpdate(update, closure); err != nil {
log.Printf("[SECURITY] [server] [capability] mint_failed id=%s pkg=%s error=%v",
id, update.PackageName, err)
}
}
}
response := gin.H{"message": "update approved"}
if len(vulns) > 0 {
response["warnings"] = vulns
}
if !ageDecision.Unknown && ageDecision.WarnMessage != "" {
response["age_warning"] = gin.H{
"message": ageDecision.WarnMessage,
"age_hours": ageDecision.AgeHours,
"min_age_hours": ageDecision.MinAgeHours,
"published_at": ageDecision.PublishedAt.UTC().Format(time.RFC3339),
}
}
c.JSON(http.StatusOK, response)
}
// computeAndStorePackageHash downloads the package artifact from upstream,
// computes its SHA256, and stores it in the database for install-time verification.
// Returns the hash string (empty on failure).
func (h *UpdateHandler) computeAndStorePackageHash(update *models.UpdateState, targetVersion string) (string, error) {
// Only compute hashes for ecosystems the server can fetch from public
// registries (npm/PyPI). Agent-sourced ecosystems (dnf/apt) report
// their hashes via ReportDependencies → pinReportedClosure instead.
if !services.CanServerFetchArtifact(update.PackageType) {
return "", nil
}
if targetVersion == "" {
targetVersion = update.AvailableVersion
}
ecosystem := services.EcosystemFromPackageType(update.PackageType)
// Call the download handler's artifact endpoint using the server's own URL
// This ensures the hash is computed from the same source agents will use
baseURL := h.config.Server.PublicURL
if baseURL == "" {
baseURL = fmt.Sprintf("http://%s:%d", h.config.Server.Host, h.config.Server.Port)
}
downloadURL := fmt.Sprintf("%s/api/v1/downloads/artifact?ecosystem=%s&package_name=%s&version=%s",
baseURL, ecosystem, update.PackageName, targetVersion)
req, err := http.NewRequest("GET", downloadURL, nil)
if err != nil {
return "", err
}
// Set basic auth for the request
client := &http.Client{Timeout: 120 * time.Second}
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("download artifact failed with status %d", resp.StatusCode)
}
// Read and compute SHA256 while streaming
sha := sha256.New()
_, err = io.Copy(sha, resp.Body)
if err != nil {
return "", err
}
shaHex := hex.EncodeToString(sha.Sum(nil))
// Store the expected hash
if err := h.updateQueries.StoreExpectedSHA256(update.ID, shaHex); err != nil {
return shaHex, err
}
return shaHex, nil
}
// ReportLog handles update execution logs from agents
func (h *UpdateHandler) ReportLog(c *gin.Context) {
agentID := c.MustGet("agent_id").(uuid.UUID)
// Update last_seen timestamp
if err := h.agentQueries.UpdateAgentLastSeen(agentID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update last seen"})
return
}
var req models.UpdateLogRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Idempotency check: reject a resubmission only once the command is
// FINALIZED (ETHOS #4). This deliberately keys off terminal status, not
// "result already recorded" — those are different questions and must not be
// conflated. A command can carry a result while still open: update_agent
// reports "started" (which records a result) and then reports its terminal
// success/failed under the same command_id. Rejecting on result-present would
// 409 that finalizing report and the command would never close. (The pending
// result-ack, by contrast, correctly clears on result-recorded — it only
// needs to know the server received the report, not that it finalized.)
if req.CommandID != "" {
commandID, err := uuid.FromString(req.CommandID)
if err == nil {
command, err := h.commandQueries.GetCommandByID(commandID)
if err == nil && command != nil {
if command.Status == models.CommandStatusCompleted || command.Status == models.CommandStatusFailed || command.Status == models.CommandStatusTimedOut {
log.Printf("[INFO] [server] [updates] duplicate_log_rejected agent_id=%s command_id=%s status=%s",
agentID, commandID, command.Status)
c.JSON(http.StatusConflict, gin.H{
"error": "duplicate log submission",
"command_id": commandID.String(),
"current_status": command.Status,
})
return
}
}
}
}
// Validate and map result to comply with database constraint.
// isValidResult is the authority; only values not in that map reach
// this fallthrough. "started" and "running" pass straight through now
// that the DB constraint accepts them (migration 048).
validResult := req.Result
if !isValidResult(validResult) {
switch validResult {
case "timed_out", "timeout", "cancelled":
validResult = "failed"
case "updated":
validResult = "success"
case "rollback":
validResult = "success"
case "partial_failure":
validResult = "partial"
case "failure":
validResult = "failed"
default:
validResult = "failed"
}
}
// Extract subsystem from request if provided, otherwise try to parse from action
subsystem := req.Subsystem
if subsystem == "" && strings.HasPrefix(req.Action, "scan_") {
subsystem = strings.TrimPrefix(req.Action, "scan_")
}
logEntry := &models.UpdateLog{
ID: uuid.Must(uuid.NewV4()),
AgentID: agentID,
Action: req.Action,
Subsystem: subsystem,
Result: validResult,
Stdout: req.Stdout,
Stderr: req.Stderr,
ExitCode: req.ExitCode,
DurationSeconds: req.DurationSeconds,
ExecutedAt: time.Now().UTC(),
}
// Add HISTORY logging
log.Printf("[INFO] [server] [update] log_created agent_id=%s subsystem=%s action=%s result=%s timestamp=%s",
agentID, subsystem, req.Action, validResult, time.Now().UTC().Format(time.RFC3339))
log.Printf("[HISTORY] [server] [update] log_created agent_id=%s subsystem=%s action=%s result=%s timestamp=%s",
agentID, subsystem, req.Action, validResult, time.Now().UTC().Format(time.RFC3339))
// Store the log entry
if err := h.updateQueries.CreateUpdateLog(logEntry); err != nil {
log.Printf("[ERROR] [server] [update] log_save_failed agent_id=%s error=%v", agentID, err)
log.Printf("[HISTORY] [server] [update] log_save_failed error=\"%v\" timestamp=%s", err, time.Now().UTC().Format(time.RFC3339))
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save log"})
return
}
// NEW: Update command status if command_id is provided
if req.CommandID != "" {
commandID, err := uuid.FromString(req.CommandID)
if err != nil {
// Log warning but don't fail the request
log.Printf("[WARNING] [server] [updates] invalid_command_id_format command_id=%s", req.CommandID)
} else {
// Prepare result data for command update
result := models.JSONB{
"stdout": req.Stdout,
"stderr": req.Stderr,
"exit_code": req.ExitCode,
"duration_seconds": req.DurationSeconds,
"logged_at": time.Now().UTC(),
}
// Update command status based on log result.
// MarkCommand* errors are ETHOS #1 violations if dropped silently — the
// agent thinks the result was recorded and won't resend. Surface via
// structured ERROR log + a should_retry hint in the response.
var markErr error
if req.Result == "success" || req.Result == "completed" {
if markErr = h.commandQueries.MarkCommandCompleted(commandID, result); markErr != nil {
log.Printf("[ERROR] [server] [updates] mark_completed_failed agent_id=%s command_id=%s error=%q",
agentID, commandID, markErr)
}
// If this was a successful confirm_dependencies command, close the
// installing package. The source-state guard keeps stale or replayed
// command results from resolving a pending/approved update.
command, err := h.commandQueries.GetCommandByID(commandID)
if err == nil && command.CommandType == models.CommandTypeConfirmDependencies {
// Extract package info from command params
if packageName, ok := command.Params["package_name"].(string); ok {
if packageType, ok := command.Params["package_type"].(string); ok {
// Extract actual completion timestamp from command result for accurate audit trail
var completionTime *time.Time
if loggedAtStr, ok := command.Result["logged_at"].(string); ok {
if parsed, err := time.Parse(time.RFC3339Nano, loggedAtStr); err == nil {
completionTime = &parsed
}
}
if err := h.updateQueries.TransitionByPackageFrom(agentID, packageType, packageName, models.StatusInstalling, models.StatusInstalled, nil, completionTime); err != nil {
log.Printf("[ERROR] [server] [updates] confirm_dependencies_status_update_failed package=%s type=%s to=%s error=%v",
packageName, packageType, models.StatusInstalled, err)
} else {
log.Printf("[INFO] [server] [updates] confirm_dependencies_status_updated package=%s type=%s status=%s",
packageName, packageType, models.StatusInstalled)
// Pin the installed version so subsequent scans don't
// silently advance to a newer unapproved version.
if upd, lookupErr := h.updateQueries.GetUpdateByPackage(agentID, packageType, packageName); lookupErr == nil {
installedVersion := upd.AvailableVersion
if upd.SelectedVersion != nil && *upd.SelectedVersion != "" {
installedVersion = *upd.SelectedVersion
}
if installedVersion != "" {
_ = h.updateQueries.SetInstalledVersionHold(upd.ID, installedVersion)
}
}
}
}
}
}
} else if req.Result == "failed" || req.Result == "failure" || req.Result == "dry_run_failed" || req.Result == "partial_failure" {
if markErr = h.commandQueries.MarkCommandFailed(commandID, result); markErr != nil {
log.Printf("[ERROR] [server] [updates] mark_failed_failed agent_id=%s command_id=%s error=%q",
agentID, commandID, markErr)
}
// If this was a failed confirm_dependencies command, close the
// installing package as failed with the same source-state guard.
command, err := h.commandQueries.GetCommandByID(commandID)
if err == nil && command.CommandType == models.CommandTypeConfirmDependencies {
if packageName, ok := command.Params["package_name"].(string); ok {
if packageType, ok := command.Params["package_type"].(string); ok {
if err := h.updateQueries.TransitionByPackageFrom(agentID, packageType, packageName, models.StatusInstalling, models.StatusFailed, nil, nil); err != nil {
log.Printf("[ERROR] [server] [updates] confirm_dependencies_status_update_failed package=%s type=%s to=%s error=%v",
packageName, packageType, models.StatusFailed, err)
} else {
log.Printf("[INFO] [server] [updates] confirm_dependencies_status_updated package=%s type=%s status=%s",
packageName, packageType, models.StatusFailed)
}
}
}
}
// A failed update_agent is definitive: clear is_updating now so the
// operator can retry immediately instead of waiting out the stuck-update
// timeout (TimeoutService reconcile). The success path is deliberately
// NOT cleared here — success is proven by the new binary attesting its
// version on check-in, not by the old binary's pre-restart log.
if err == nil && command.CommandType == models.CommandTypeUpdateAgent {
if clearErr := h.agentQueries.ClearAgentUpdating(agentID); clearErr != nil {
log.Printf("[ERROR] [server] [updates] clear_updating_on_fail_failed agent_id=%s command_id=%s error=%q",
agentID, commandID, clearErr)
} else {
log.Printf("[INFO] [server] [updates] agent_update_failed_flag_cleared agent_id=%s command_id=%s",
agentID, commandID)
}
}
} else {
// For other results, just update the result field
if markErr = h.commandQueries.UpdateCommandResult(commandID, result); markErr != nil {
log.Printf("[ERROR] [server] [updates] update_result_failed agent_id=%s command_id=%s error=%q",
agentID, commandID, markErr)
}
}
if markErr != nil {
c.JSON(http.StatusOK, gin.H{
"message": "log saved but command state update failed",
"should_retry": true,
"reason": markErr.Error(),
})
return
}
}
}
// ETHOS #1: every failed agent action is an auditable error — journal it inward
// so it surfaces in the events API / history, not only in update_logs. This was
// previously gated to update_agent/verify_command, which left scan, dry-run, and
// confirm_dependencies failures invisible to the operator. Best-effort — log and
// continue on failure, don't block the response.
if validResult == "failed" {
failureReason := req.Stderr
if failureReason == "" && req.ExitCode != 0 {
failureReason = fmt.Sprintf("exit code %d", req.ExitCode)
}
if failureReason == "" {
failureReason = "agent reported failure"
}
// Preserve the existing event type for update flows; everything else is a
// generic command failure.
eventType := models.EventTypeCommandFailed
if req.Action == "update_agent" {
eventType = "agent_update"
}
message := services.RenderUpdateLog(req.Action, validResult, req.Stderr)
event := &models.SystemEvent{
ID: uuid.Must(uuid.NewV4()),
AgentID: &agentID,
EventType: eventType,
EventSubtype: "failed",
Severity: "error",
Component: "agent",
Message: message,
Metadata: map[string]interface{}{
"action": req.Action,
"result": validResult,
"stderr": req.Stderr,
"failure_reason": failureReason,
},
CreatedAt: time.Now().UTC(),
}
if err := h.agentQueries.CreateSystemEvent(event); err != nil {
log.Printf("[WARNING] [server] [updates] system_event_write_failed agent_id=%s action=%s error=%v",
agentID, req.Action, err)
}
}
c.JSON(http.StatusOK, gin.H{"message": "log recorded"})
}
// GetPackageHistory returns version history for a specific package
func (h *UpdateHandler) GetPackageHistory(c *gin.Context) {
agentIDStr := c.Param("agent_id")
agentID, err := uuid.FromString(agentIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid agent ID"})
return
}
packageType := c.Query("package_type")
packageName := c.Query("package_name")
if packageType == "" || packageName == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "package_type and package_name are required"})
return
}
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
history, err := h.updateQueries.GetPackageHistory(agentID, packageType, packageName, limit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get package history"})
return
}
c.JSON(http.StatusOK, gin.H{
"history": history,
"package_type": packageType,
"package_name": packageName,
"count": len(history),
})
}
// GetBatchStatus returns recent batch processing status for an agent
func (h *UpdateHandler) GetBatchStatus(c *gin.Context) {
agentIDStr := c.Param("agent_id")
agentID, err := uuid.FromString(agentIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid agent ID"})
return
}
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
batches, err := h.updateQueries.GetBatchStatus(agentID, limit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get batch status"})
return
}
c.JSON(http.StatusOK, gin.H{
"batches": batches,
"count": len(batches),
})
}
// UpdatePackageStatus updates the status of a package (for when updates are installed)
func (h *UpdateHandler) UpdatePackageStatus(c *gin.Context) {
agentIDStr := c.Param("agent_id")
agentID, err := uuid.FromString(agentIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid agent ID"})
return
}
var req struct {
PackageType string `json:"package_type" binding:"required"`
PackageName string `json:"package_name" binding:"required"`
Status models.PackageStatus `json:"status" binding:"required"`
Metadata map[string]interface{} `json:"metadata"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.updateQueries.UpdatePackageStatus(agentID, req.PackageType, req.PackageName, req.Status, req.Metadata, nil); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update package status"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "package status updated"})
}
// ApproveUpdates handles bulk approval of updates, running the same OSV.dev
// supply chain check as single-approve for npm/PyPI packages. Per-update
// loop instead of BulkApproveUpdates because the OSV-enriched approval
// stores per-update metadata; the warning aggregate is returned to the UI.
func (h *UpdateHandler) ApproveUpdates(c *gin.Context) {
var req struct {
UpdateIDs []string `json:"update_ids" binding:"required"`
ScheduledAt *string `json:"scheduled_at"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
type warning struct {
UpdateID string `json:"update_id"`
PackageName string `json:"package_name"`
Vulnerabilities []services.VulnerabilityInfo `json:"vulnerabilities,omitempty"`
AgeWarning string `json:"age_warning,omitempty"`
AgeHours float64 `json:"age_hours,omitempty"`
}
type blocked struct {
UpdateID string `json:"update_id"`
PackageName string `json:"package_name"`
Reason string `json:"reason"`
AgeHours float64 `json:"age_hours"`
}
warnings := make([]warning, 0)
blockedList := make([]blocked, 0)
approved := 0
// Bulk reads the gate config once — applies to every item in the batch.
gateMin, gateEnforcement, gateBlockUnknown := h.resolvePackageAgeGateConfig()
for _, idStr := range req.UpdateIDs {
id, err := uuid.FromString(idStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid update ID: " + idStr})
return
}
update, err := h.updateQueries.GetUpdateByID(id)
if err != nil {
log.Printf("[ERROR] [server] [updates] bulk_approve_lookup_failed id=%s error=%v", id, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to load update %s", idStr)})
return
}
targetVersion, _ := h.resolveInstallTarget(update)
// OSV verdict comes from detection-time metadata (see persistedSupplyChainVulns
// and the single-approve path); bulk approval never re-scans OSV.
vulns := persistedSupplyChainVulns(update, targetVersion)
var ageDecision services.PackageAgeGateDecision
if services.NeedsSupplyChainCheck(update.PackageType) {
ecosystem := services.EcosystemFromPackageType(update.PackageType)
if gateEnforcement != "off" {
age := services.GetPackagePublishDate(update.PackageName, ecosystem, targetVersion)
ageDecision = services.EvaluatePackageAgeGate(age, services.PackageAgeGatePolicy{
MinAgeHours: gateMin,
Enforcement: gateEnforcement,
BlockUnknownAge: gateBlockUnknown,
EcosystemAged: services.EcosystemSupportsPackageAge(ecosystem),
})
recordPackageAgeMetadata(update, ageDecision)
if ageDecision.ShouldBlock {
log.Printf("[WARNING] [supply_chain] bulk_approval_blocked_by_age_gate id=%s pkg=%s age_hours=%.2f",
id, update.PackageName, ageDecision.AgeHours)
blockedList = append(blockedList, blocked{
UpdateID: idStr,
PackageName: update.PackageName,
Reason: ageDecision.WarnMessage,
AgeHours: ageDecision.AgeHours,
})
continue
}
}
}
// Supply-chain full stop, same gate as single-approve. Bulk approval never
// carries a blanket override — a known vuln or an unverifiable closure is
// returned as blocked, and the operator must approve that one individually
// with an explicit reason. Keeps "override everything at once" impossible.
if hold := h.evaluateSupplyChainHold(update, targetVersion, vulns); hold.blocked {
log.Printf("[SECURITY] [server] [supply_chain] bulk_approval_blocked id=%s pkg=%s reason=%q unverified=%t",
id, update.PackageName, hold.reason, hold.unverified)
blockedList = append(blockedList, blocked{
UpdateID: idStr,
PackageName: update.PackageName,
Reason: hold.reason,
})
continue
}
// Layer 1: Hash Registry — compute and store expected_sha256 BEFORE writing
// the approval. Fail closed: hash error blocks this item (added to blockedList)
// without aborting the rest of the batch. Empty hash with no error is fine
// (agent-sourced ecosystems report hashes via ReportDependencies).
artifactHash, err := h.computeAndStorePackageHash(update, targetVersion)
if err != nil {
log.Printf("[ERROR] [server] [updates] bulk_hash_computation_failed id=%s pkg=%s error=%v",
id, update.PackageName, err)
blockedList = append(blockedList, blocked{
UpdateID: idStr,
PackageName: update.PackageName,
Reason: fmt.Sprintf("hash computation failed: %v", err),
})
continue
}
if artifactHash != "" {
log.Printf("[INFO] [server] [updates] bulk_hash_stored id=%s pkg=%s sha256=%s",
id, update.PackageName, artifactHash[:16]+"...")
}
needsMeta := len(vulns) > 0 || (!ageDecision.Unknown && ageDecision.WarnMessage != "")
if needsMeta {
if err := h.updateQueries.ApproveUpdateWithVulns(id, "admin", update.Metadata); err != nil {
log.Printf("[ERROR] [server] [updates] bulk_approve_failed id=%s error=%v", id, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to approve update %s: %v", idStr, err)})
return
}
w := warning{UpdateID: idStr, PackageName: update.PackageName, Vulnerabilities: vulns}
if !ageDecision.Unknown && ageDecision.WarnMessage != "" {
w.AgeWarning = ageDecision.WarnMessage
w.AgeHours = ageDecision.AgeHours
}
warnings = append(warnings, w)
} else {
if err := h.updateQueries.ApproveUpdate(id, "admin"); err != nil {
log.Printf("[ERROR] [server] [updates] bulk_approve_failed id=%s error=%v", id, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to approve update %s: %v", idStr, err)})
return
}
}
approved++
}
response := gin.H{
"message": "updates approved",
"count": approved,
}
if len(warnings) > 0 {
response["warnings"] = warnings
}
if len(blockedList) > 0 {
response["blocked"] = blockedList
response["blocked_count"] = len(blockedList)
}
c.JSON(http.StatusOK, response)
}
// RejectUpdate rejects a single update
func (h *UpdateHandler) RejectUpdate(c *gin.Context) {
idStr := c.Param("id")
id, err := uuid.FromString(idStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid update ID"})
return
}
// For now, use "admin" as rejecter. Will integrate with proper auth later
if err := h.updateQueries.RejectUpdate(id, "admin"); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to reject update"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "update rejected"})
}
// InstallUpdate marks an update as ready for installation and creates a dry run command for the agent
func (h *UpdateHandler) InstallUpdate(c *gin.Context) {
idStr := c.Param("id")
id, err := uuid.FromString(idStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid update ID"})
return
}
// Get the full update details to extract agent_id, package_name, and package_type
update, err := h.updateQueries.GetUpdateByID(id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get update details"})
return
}
// Check maintenance window before proceeding
if inside, err := h.maintenanceWindowQueries.IsWithinMaintenanceWindow(time.Now().UTC()); err != nil {
log.Printf("[ERROR] [server] [maintenance_window] check_failed id=%s error=%v", id, err)
// Fail-open on DB error: allow the operation but log the failure
} else if !inside {
c.JSON(http.StatusForbidden, gin.H{
"error": "Installation blocked: outside configured maintenance window",
})
return
}
// Policy gate: dry runs may be disabled per-deployment. Strict default is
// "dry run required" — when policy.allow_dry_runs=false an operator has
// explicitly chosen to skip the dependency-check step. Refused with 403
// here so the rejection surfaces in history; structural "queue install
// directly" semantics may follow in a later scope.
allowDryRuns := true
if h.securitySettings != nil {
allowDryRuns = h.securitySettings.GetPolicyBool("allow_dry_runs", true)
}
if !allowDryRuns {
log.Printf("[INFO] [server] [updates] dry_run_refused update_id=%s reason=policy.allow_dry_runs=false agent_id=%s",
id, update.AgentID)
c.JSON(http.StatusForbidden, gin.H{
"error": "Dry runs are disabled by policy (policy.allow_dry_runs=false). Operator action required to proceed.",
"_error_context": "policy_dry_runs_disabled",
})
return
}
// Create the signed dry-run command and advance to checking_dependencies.
// Shared with the orchestrator's auto-approve path via EnqueueDryRun.
if err := h.EnqueueDryRun(update); err != nil {
log.Printf("[ERROR] [server] [updates] enqueue_dry_run_failed update_id=%s error=%v", id, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to start dry run"})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "dry run command created for agent",
})
}
// InstallVersion selects a specific version for installation, evaluating the
// version soak gate before proceeding. POST /updates/:id/install-version
func (h *UpdateHandler) InstallVersion(c *gin.Context) {
idStr := c.Param("id")
id, err := uuid.FromString(idStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid update ID"})
return
}
var req models.InstallVersionRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
update, err := h.updateQueries.GetUpdateByID(id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "update not found"})
return
}
// Check maintenance window before proceeding.
if inside, err := h.maintenanceWindowQueries.IsWithinMaintenanceWindow(time.Now().UTC()); err != nil {
log.Printf("[ERROR] [server] [maintenance_window] check_failed id=%s error=%v", id, err)
} else if !inside {
c.JSON(http.StatusForbidden, gin.H{
"error": "Installation blocked: outside configured maintenance window",
})
return
}
// Validate version exists in the catalog.
pv, err := h.updateQueries.GetPackageVersionByString(update.PackageType, update.PackageName, req.Version)
if err != nil {
log.Printf("[ERROR] [server] [soak_gate] version_lookup_failed id=%s version=%s error=%v", id, req.Version, err)
c.JSON(http.StatusNotFound, gin.H{"error": "version not found in package catalog"})
return
}
if pv == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "version not found in package catalog"})
return
}
// Evaluate soak gate.
requiredDays, enforcement := h.resolveSoakGateConfig()
decision := services.EvaluateSoakGate(pv.FirstScannedAt, requiredDays, enforcement)
if !decision.Eligible && !decision.Unknown {
if strings.TrimSpace(req.OverrideReason) == "" {
log.Printf("[SECURITY] [server] [soak_gate] install_blocked id=%s pkg=%s version=%s soak_days=%.1f required=%.1f remaining=%.1f",
id, update.PackageName, req.Version, decision.SoakDays, decision.RequiredDays, decision.DaysRemaining)
c.JSON(http.StatusConflict, gin.H{
"error": "install blocked by version soak gate",
"reason": services.SoakGateWarnMessage(decision),
"version": req.Version,
"soak_days": decision.SoakDays,
"required_days": decision.RequiredDays,
"days_remaining": decision.DaysRemaining,
"first_scanned_at": decision.FirstScannedAt.UTC().Format(time.RFC3339),
"override_hint": "resubmit with a non-empty override_reason to install anyway",
})
return
}
h.recordSoakOverride(update, decision, req.OverrideReason)
}
// Set selected_version on the update row, then update the local struct to match.
if err := h.updateQueries.SetTargetVersion(id, req.Version); err != nil {
log.Printf("[ERROR] [server] [soak_gate] set_target_failed id=%s version=%s error=%v", id, req.Version, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to set target version"})
return
}
update.SelectedVersion = &req.Version
// [GATE-006 slice A] Re-run OSV against the version that will actually install.
// Detection (enqueueOSVChecks) checked available_version — the newest upstream.
// The operator just pinned a (soak-gated) selected_version, which is what the
// gate installs (target resolves selected_version ?? available_version). The
// persisted supply_chain_vulns must describe THAT version, since
// evaluateSupplyChainHold reads it at approval. Checking the version we never
// install while installing one we never checked hollows the supply-chain
// guarantee. Background + best-effort, mirroring the detection path.
if services.NeedsSupplyChainCheck(update.PackageType) {
agentID, pkgType, pkgName, target := update.AgentID, update.PackageType, update.PackageName, req.Version
h.bg("osv_recheck_selected", func() {
services.RunOSVChecks(
[]services.OSVCheckRequest{{AgentID: agentID, PkgType: pkgType, PkgName: pkgName, Version: target}},
func(aID uuid.UUID, pt, pn string, meta map[string]interface{}) error {
return storeOSVCheckResult(h.updateQueries, aID, pt, pn, meta)
},
)
})
}
if err := h.EnqueueDryRun(update); err != nil {
log.Printf("[ERROR] [server] [soak_gate] enqueue_dry_run_failed id=%s version=%s error=%v", id, req.Version, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to start dry run for selected version"})
return
}
log.Printf("[INFO] [server] [soak_gate] install_version_selected id=%s pkg=%s version=%s soak_eligible=%t",
id, update.PackageName, req.Version, decision.Eligible)
c.JSON(http.StatusOK, gin.H{
"message": "version selected and dry run enqueued",
"selected_version": req.Version,
"soak_days": decision.SoakDays,
"required_days": decision.RequiredDays,
})
}
// recordSoakOverride journals an operator's decision to install before soak completes.
func (h *UpdateHandler) recordSoakOverride(update *models.UpdateState, dec services.SoakGateDecision, operatorReason string) {
if h.agentQueries == nil {
return
}
h.recordGateOverride(update, "soak_override", "", "soak_gate",
fmt.Sprintf("Operator overrode version soak gate for %s (%.1f/%.0f days): %s",
update.PackageName, dec.SoakDays, dec.RequiredDays, operatorReason),
map[string]interface{}{
"package_name": update.PackageName,
"soak_days": dec.SoakDays,
"required_days": dec.RequiredDays,
"days_remaining": dec.DaysRemaining,
"operator_reason": operatorReason,
})
}
// GetUpdateLogs retrieves installation logs for a specific update
func (h *UpdateHandler) GetUpdateLogs(c *gin.Context) {
idStr := c.Param("id")
id, err := uuid.FromString(idStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid update ID"})
return
}
// Parse limit from query params
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
logs, err := h.updateQueries.GetUpdateLogs(id, limit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to retrieve update logs"})
return
}
c.JSON(http.StatusOK, gin.H{
"logs": logs,
"count": len(logs),
})
}
// ReportDependencies handles dependency reporting from agents after dry run
func (h *UpdateHandler) ReportDependencies(c *gin.Context) {
agentID := c.MustGet("agent_id").(uuid.UUID)
// Update last_seen timestamp
if err := h.agentQueries.UpdateAgentLastSeen(agentID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update last seen"})
return
}
var req models.DependencyReportRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Hash registry (OS package managers): dry-run records the canonical artifact
// hashes the agent's signed repo metadata anchors. This pins the closure, but
// does not mint an executable capability token yet; final operator intent is
// established below for no-dependency updates or in ConfirmDependencies.
if err := h.pinReportedClosure(agentID, &req); err != nil {
log.Printf("[SECURITY] [server] [capability] closure_pin_rejected agent_id=%s pkg=%s/%s error=%v",
agentID, req.PackageType, req.PackageName, err)
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
return
}
// If there are NO dependencies, auto-approve and proceed directly to installation
// This prevents updates with zero dependencies from getting stuck in "pending_dependencies"
if len(req.Dependencies) == 0 {
// Get the update by package to retrieve its ID
update, err := h.updateQueries.GetUpdateByPackage(agentID, req.PackageType, req.PackageName)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get update details"})
return
}
if h.usesCapabilityExecution(update.PackageType) {
closure, err := h.updateQueries.GetResolvedClosure(update.ID)
if err != nil || len(closure) == 0 {
log.Printf("[SECURITY] [server] [capability] no_pinned_closure update_id=%s pkg=%s — agent reported zero deps but no hashed closure was stored",
update.ID, update.PackageName)
c.JSON(http.StatusConflict, gin.H{"error": "no hashed closure available to sign — agent must include artifact hashes in the closure report"})
return
}
if err := h.updateQueries.SetInstallingWithNoDependencies(update.ID, req.Dependencies); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update package status to installing"})
return
}
if _, err := h.mintResolvedClosure(update); err != nil {
log.Printf("[SECURITY] [server] [capability] auto_mint_failed update_id=%s pkg=%s error=%v",
update.ID, update.PackageName, err)
if statusErr := h.updateQueries.UpdatePackageStatus(update.AgentID, update.PackageType, update.PackageName, models.StatusFailed, models.JSONB{
"capability_authorization_error": err.Error(),
}, nil); statusErr != nil {
log.Printf("[ERROR] [server] [capability] auto_mint_failed_status_update_failed update_id=%s error=%v",
update.ID, statusErr)
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to authorize capability token"})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "no dependencies found - capability token authorized for agent execution",
})
return
}
// Automatically create installation command since no dependencies need approval
command := &models.AgentCommand{
ID: uuid.Must(uuid.NewV4()),
AgentID: agentID,
CommandType: models.CommandTypeConfirmDependencies,
Params: map[string]interface{}{
"update_id": update.ID.String(),
"package_name": req.PackageName,
"package_type": req.PackageType,
"dependencies": []string{}, // Empty dependencies array
},
Status: models.CommandStatusPending,
Source: models.CommandSourceManual,
CreatedAt: time.Now().UTC(),
}
// Check if heartbeat should be enabled (avoid duplicates)
if shouldEnable, err := h.shouldEnableHeartbeat(agentID, 10); err == nil && shouldEnable {
heartbeatCmd := &models.AgentCommand{
ID: uuid.Must(uuid.NewV4()),
AgentID: agentID,
CommandType: models.CommandTypeEnableHeartbeat,
Params: models.JSONB{
"duration_minutes": 10,
},
Status: models.CommandStatusPending,
Source: models.CommandSourceSystem,
CreatedAt: time.Now().UTC(),
}
if err := h.agentHandler.signAndCreateCommand(heartbeatCmd); err != nil {
log.Printf("[Heartbeat] Warning: Failed to create heartbeat command for agent %s: %v", agentID, err)
} else {
log.Printf("[Heartbeat] Command created for agent %s before installation", agentID)
}
} else {
log.Printf("[Heartbeat] Skipping heartbeat command for agent %s (already active)", agentID)
}
if err := h.agentHandler.signAndCreateCommand(command); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create installation command"})
return
}
// Record that dependencies were checked (empty array) and transition directly to installing
if err := h.updateQueries.SetInstallingWithNoDependencies(update.ID, req.Dependencies); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update package status to installing"})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "no dependencies found - installation command created automatically",
"command_id": command.ID.String(),
})
return
}
// Dependencies EXIST: park the package in pending_dependencies. An operator
// confirms it from the dashboard — unless auto-approval policy covers it, in
// which case the orchestrator auto-confirms and mints the token below.
if err := h.updateQueries.SetPendingDependencies(agentID, req.PackageType, req.PackageName, req.Dependencies); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update package status"})
return
}
// Dependency-level supply-chain gate: the closure resolved at dry-run is the
// exact artifact set the capability token will authorize the network-less
// executor to install. The discovery-time OSV check only covered the
// top-level package, so check every closure entry now. The result is
// persisted on the row (closure_vulns, visible to the operator) and, when the
// closure comes back clean, the orchestrator is triggered to auto-confirm if
// policy + window allow. Async so the agent's report returns promptly.
if update, err := h.updateQueries.GetUpdateByPackage(agentID, req.PackageType, req.PackageName); err == nil {
updateID, pkgType, closure := update.ID, req.PackageType, req.Closure
h.bg("closure_advance", func() { h.checkClosureAndAdvance(updateID, pkgType, closure) })
} else {
log.Printf("[ERROR] [server] [capability] closure_check_lookup_failed agent=%s pkg=%s/%s error=%v",
agentID, req.PackageType, req.PackageName, err)
}
c.JSON(http.StatusOK, gin.H{"message": "dependencies reported and status updated"})
}
// checkClosureAndAdvance runs the dependency-closure OSV check for a package
// that has just entered pending_dependencies, persists the result on the row,
// and — only when the closure is checked AND clean — triggers the orchestrator
// to auto-confirm without waiting for the next timer sweep. Fail-closed: if OSV
// cannot vet the closure, closure_checked_at is left unset and auto-confirm
// holds off until a later report re-attempts the check. A closure with vulns is
// recorded and left for an operator — auto-confirm never fires over it.
func (h *UpdateHandler) checkClosureAndAdvance(updateID uuid.UUID, pkgType string, closure []models.ClosureItem) {
if !services.NeedsSupplyChainCheck(pkgType) {
// No OSV mapping for this ecosystem — nothing to vet. Mark the closure
// checked-and-clean so the gate doesn't stall, then advance.
if err := h.updateQueries.StoreClosureCheck(updateID, "[]"); err != nil {
log.Printf("[ERROR] [server] [capability] closure_check_store_failed update_id=%s error=%v", updateID, err)
return
}
} else {
entries := make([]services.ClosurePkg, 0, len(closure))
for _, e := range closure {
if e.Name == "" || e.Version == "" {
continue
}
entries = append(entries, services.ClosurePkg{Name: e.Name, Version: e.Version})
}
found, ok := services.CheckClosureOSV(pkgType, entries)
if !ok {
// Fail-closed: a closure OSV could not vet is not "clean".
log.Printf("[SECURITY] [server] [capability] closure_osv_incomplete update_id=%s closure_size=%d — not cleared, auto-confirm held",
updateID, len(entries))
if err := h.updateQueries.RecordClosureCheckError(updateID, "osv_closure_check_failed"); err != nil {
log.Printf("[ERROR] [server] [capability] closure_error_store_failed update_id=%s error=%v", updateID, err)
}
return
}
closureVulnsJSON := "[]"
if len(found) > 0 {
if b, err := json.Marshal(found); err == nil {
closureVulnsJSON = string(b)
}
log.Printf("[SECURITY] [server] [capability] closure_vulns_found update_id=%s artifacts=%d — auto-confirm blocked, operator review required",
updateID, len(found))
}
if err := h.updateQueries.StoreClosureCheck(updateID, closureVulnsJSON); err != nil {
log.Printf("[ERROR] [server] [capability] closure_check_store_failed update_id=%s error=%v", updateID, err)
return
}
if len(found) > 0 {
return // vulnerable closure — operator decides, no auto-advance
}
}
if h.orchestrator != nil {
h.orchestrator.OnDependenciesReported(updateID)
}
}
// pinReportedClosure stores the top-level expected SHA256 and the reported
// closure. It deliberately does not mint an executable token: dependency
// confirmation is the authorization boundary.
func (h *UpdateHandler) pinReportedClosure(agentID uuid.UUID, req *models.DependencyReportRequest) error {
updateID, err := uuid.FromString(req.UpdateID)
if err != nil {
return fmt.Errorf("invalid update id for closure pin: %w", err)
}
update, err := h.updateQueries.GetUpdateByID(updateID)
if err != nil {
return fmt.Errorf("load update for closure pin: %w", err)
}
// Trust boundary: the report is agent-authenticated; only let an agent pin a
// hash onto its own update row.
if update.AgentID != agentID {
return fmt.Errorf("closure report agent mismatch")
}
if len(req.Closure) == 0 {
if services.NeedsCapabilityGate(update.PackageType) {
return fmt.Errorf("hashed closure required for capability-gated package %s/%s", req.PackageType, req.PackageName)
}
return nil
}
targetVersion, _ := h.resolveInstallTarget(update)
if req.TargetVersion != "" && targetVersion != "" && strings.TrimSpace(req.TargetVersion) != targetVersion {
return fmt.Errorf("dry-run target mismatch: report=%s expected=%s", req.TargetVersion, targetVersion)
}
top, ok := topLevelClosure(req.Closure, req.PackageName)
if !ok {
return fmt.Errorf("closure missing top-level artifact for %s", req.PackageName)
}
if top.SHA256 == "" {
return fmt.Errorf("closure top-level artifact for %s has no sha256", req.PackageName)
}
if targetVersion != "" && top.Version != targetVersion {
return fmt.Errorf("closure top-level version mismatch: report=%s expected=%s", top.Version, targetVersion)
}
// Pin the top-level artifact hash for Layer-1 install-time verification.
if err := h.updateQueries.StoreExpectedSHA256(update.ID, top.SHA256); err != nil {
return fmt.Errorf("store expected sha256: %w", err)
}
log.Printf("[INFO] [server] [capability] hash_pinned update_id=%s pkg=%s version=%s sha256=%s closure_size=%d",
updateID, req.PackageName, top.Version, shortSHA(top.SHA256), len(req.Closure))
closure := make([]capability.ClosureEntry, 0, len(req.Closure))
for _, e := range req.Closure {
if e.SHA256 == "" {
continue
}
closure = append(closure, capability.ClosureEntry{
Name: e.Name,
Version: e.Version,
SHA256: e.SHA256,
Source: e.Source,
})
// Each resolved artifact is a version with a known hash — record it in the
// timeline catalog (same package_type as the update being installed).
sha := e.SHA256
pv := &models.PackageVersion{
PackageType: update.PackageType,
PackageName: e.Name,
Version: e.Version,
SHA256: &sha,
}
if e.Source != "" {
src := e.Source
pv.Source = &src
}
if err := h.updateQueries.UpsertPackageVersion(pv); err != nil {
log.Printf("[WARNING] [server] [versions] closure_catalog_failed update_id=%s pkg=%s error=%v",
update.ID, e.Name, err)
}
}
if len(closure) == 0 {
return fmt.Errorf("closure contains no hashed artifacts")
}
if err := h.updateQueries.StoreResolvedClosure(update.ID, closure); err != nil {
return fmt.Errorf("store resolved closure: %w", err)
} else {
log.Printf("[INFO] [server] [capability] closure_stored update_id=%s closure_size=%d", updateID, len(closure))
}
return nil
}
func topLevelClosure(closure []models.ClosureItem, packageName string) (models.ClosureItem, bool) {
for _, e := range closure {
if e.Name == packageName {
return e, true
}
}
return models.ClosureItem{}, false
}
func shortSHA(s string) string {
if len(s) <= 16 {
return s
}
return s[:16] + "..."
}
func (h *UpdateHandler) usesCapabilityExecution(packageType string) bool {
return h.minter != nil && h.minter.Enabled() && h.tokenQueries != nil && services.NeedsCapabilityGate(packageType)
}
func (h *UpdateHandler) mintResolvedClosure(update *models.UpdateState) (*capability.Token, error) {
if !h.usesCapabilityExecution(update.PackageType) {
return nil, nil
}
closure, err := h.updateQueries.GetResolvedClosure(update.ID)
if err != nil {
return nil, err
}
if len(closure) == 0 {
return nil, fmt.Errorf("no resolved closure stored for update")
}
if active, err := h.tokenQueries.HasActiveForUpdate(update.ID, time.Now().UTC().Unix()); err != nil {
return nil, err
} else if active {
log.Printf("[INFO] [server] [capability] mint_skipped update_id=%s reason=active_token_exists", update.ID)
return nil, nil
}
return h.minter.MintForUpdate(update, closure)
}
// enableHeartbeatBeforeInstall asks the agent to poll rapidly so it picks up the
// minted token / install command without waiting a full poll interval. Best
// effort and de-duplicated: a no-op when a heartbeat is already active.
func (h *UpdateHandler) enableHeartbeatBeforeInstall(agentID uuid.UUID) {
shouldEnable, err := h.shouldEnableHeartbeat(agentID, 10)
if err != nil || !shouldEnable {
if err != nil {
log.Printf("[Heartbeat] Warning: heartbeat check failed for agent %s: %v", agentID, err)
}
return
}
heartbeatCmd := &models.AgentCommand{
ID: uuid.Must(uuid.NewV4()),
AgentID: agentID,
CommandType: models.CommandTypeEnableHeartbeat,
Params: models.JSONB{"duration_minutes": 10},
Status: models.CommandStatusPending,
Source: models.CommandSourceSystem,
CreatedAt: time.Now().UTC(),
}
if err := h.agentHandler.signAndCreateCommand(heartbeatCmd); err != nil {
log.Printf("[Heartbeat] Warning: Failed to create heartbeat command for agent %s: %v", agentID, err)
}
}
// confirmCapabilityInstall is the capability-path confirm core shared by the
// operator-driven ConfirmDependencies endpoint and the orchestrator's headless
// ConfirmDependenciesAuto. It enables a heartbeat, transitions the package to
// installing, and mints the capability token over the already-stored closure.
// On mint failure it marks the package failed and returns the error. The
// maintenance window and capability-path eligibility are the caller's
// responsibility — this method assumes both have been checked.
func (h *UpdateHandler) confirmCapabilityInstall(update *models.UpdateState) error {
h.enableHeartbeatBeforeInstall(update.AgentID)
if err := h.updateQueries.InstallUpdate(update.ID); err != nil {
return fmt.Errorf("set installing: %w", err)
}
if _, err := h.mintResolvedClosure(update); err != nil {
log.Printf("[SECURITY] [server] [capability] confirm_mint_failed update_id=%s pkg=%s error=%v",
update.ID, update.PackageName, err)
if statusErr := h.updateQueries.UpdatePackageStatus(update.AgentID, update.PackageType, update.PackageName, models.StatusFailed, models.JSONB{
"capability_authorization_error": err.Error(),
}, nil); statusErr != nil {
log.Printf("[ERROR] [server] [capability] confirm_mint_failed_status_update_failed update_id=%s error=%v",
update.ID, statusErr)
}
return err
}
return nil
}
// ConfirmDependenciesAuto is the orchestrator's headless entry to the
// capability-path confirm core (satisfies orchestrator.DependencyConfirmer). It
// returns handled=false for non-capability (legacy) packages so the orchestrator
// leaves them for the operator. The caller has already checked the maintenance
// window and auto-approval policy.
func (h *UpdateHandler) ConfirmDependenciesAuto(update *models.UpdateState) (bool, error) {
if !h.usesCapabilityExecution(update.PackageType) {
return false, nil
}
if err := h.confirmCapabilityInstall(update); err != nil {
return true, err
}
return true, nil
}
// ConfirmDependencies handles user confirmation to proceed with dependency installation
func (h *UpdateHandler) ConfirmDependencies(c *gin.Context) {
idStr := c.Param("id")
id, err := uuid.FromString(idStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid update ID"})
return
}
// Get the update details
update, err := h.updateQueries.GetUpdateByID(id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "update not found"})
return
}
// Check maintenance window before proceeding
if inside, err := h.maintenanceWindowQueries.IsWithinMaintenanceWindow(time.Now().UTC()); err != nil {
log.Printf("[ERROR] [server] [maintenance_window] check_failed id=%s error=%v", id, err)
} else if !inside {
c.JSON(http.StatusForbidden, gin.H{
"error": "Installation blocked: outside configured maintenance window",
})
return
}
// Capability-gated packages (dnf/apt) go through the token path: transition
// to installing and mint the signed closure. Shared with the orchestrator's
// headless auto-confirm via confirmCapabilityInstall.
if h.usesCapabilityExecution(update.PackageType) {
if err := h.confirmCapabilityInstall(update); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to authorize capability token"})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "dependency installation confirmed and capability token authorized for agent execution",
})
return
}
// Legacy command path (docker/winget/Windows): send a confirm_dependencies
// command for the agent to execute directly.
command := &models.AgentCommand{
ID: uuid.Must(uuid.NewV4()),
AgentID: update.AgentID,
CommandType: models.CommandTypeConfirmDependencies,
Params: map[string]interface{}{
"update_id": id.String(),
"package_name": update.PackageName,
"package_type": update.PackageType,
"dependencies": update.Metadata["dependencies"], // Dependencies stored in metadata
},
Status: models.CommandStatusPending,
Source: models.CommandSourceManual,
CreatedAt: time.Now().UTC(),
}
h.enableHeartbeatBeforeInstall(update.AgentID)
// Store the command in database
if err := h.agentHandler.signAndCreateCommand(command); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create confirmation command"})
return
}
// Update the package status to 'installing'
if err := h.updateQueries.InstallUpdate(id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update package status"})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "dependency installation confirmed and command created",
"command_id": command.ID.String(),
})
}
// GetAllLogs retrieves logs across all agents with filtering for universal log view
// Returns the fleet activity feed across all five event sources.
func (h *UpdateHandler) GetAllLogs(c *gin.Context) {
filters := &models.LogFilters{
Action: c.Query("action"),
Result: c.Query("result"),
Type: c.Query("type"),
Severity: c.Query("severity"),
}
// Parse agent_id if provided
if agentIDStr := c.Query("agent_id"); agentIDStr != "" {
agentID, err := uuid.FromString(agentIDStr)
if err == nil {
filters.AgentID = agentID
}
}
// Parse since timestamp if provided
if sinceStr := c.Query("since"); sinceStr != "" {
sinceTime, err := time.Parse(time.RFC3339, sinceStr)
if err == nil {
filters.Since = &sinceTime
}
}
// Parse pagination
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "100"))
filters.Page = page
filters.PageSize = pageSize
// Get fleet activity across all event sources
items, total, err := h.updateQueries.GetFleetActivity(filters)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to retrieve history"})
return
}
// Populate narrative so the UI never has to compose its own verbiage.
// A retry carries the same action/result as its original, so the renderer
// stays focused on the outcome and the retry context is prefixed here.
for i := range items {
narrative := services.RenderUpdateLog(items[i].Action, items[i].Result, items[i].Stderr)
if items[i].IsRetry {
narrative = "Retry — " + narrative
}
items[i].Narrative = narrative
}
c.JSON(http.StatusOK, gin.H{
"logs": items, // JSON key kept as "logs" for frontend compatibility
"total": total,
"page": page,
"page_size": pageSize,
})
}
// GetActiveOperations retrieves currently running operations for live status view
func (h *UpdateHandler) GetActiveOperations(c *gin.Context) {
operations, err := h.updateQueries.GetActiveOperations()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to retrieve active operations"})
return
}
c.JSON(http.StatusOK, gin.H{
"operations": operations,
"count": len(operations),
})
}
// buildRetryCommand constructs a new pending command that retries `original`,
// signs it via the agent handler's keying flow (F-5: retries must re-sign), and
// persists it. Returns the new command on success.
//
// This is the single security-sensitive code path used by RetryCommand (caller
// supplies the command ID directly). Failed updates now reopen/resolve through
// the lifecycle endpoints rather than a command-level retry.
func (h *UpdateHandler) buildRetryCommand(original *models.AgentCommand) (*models.AgentCommand, error) {
newCommand := &models.AgentCommand{
ID: uuid.Must(uuid.NewV4()),
AgentID: original.AgentID,
CommandType: original.CommandType,
Params: original.Params,
Status: models.CommandStatusPending,
Source: original.Source,
CreatedAt: time.Now().UTC(),
RetriedFromID: &original.ID,
}
if err := h.agentHandler.signAndCreateCommand(newCommand); err != nil {
return nil, err
}
return newCommand, nil
}
// RetryCommand retries a failed, timed_out, or cancelled command by command ID.
func (h *UpdateHandler) RetryCommand(c *gin.Context) {
idStr := c.Param("id")
id, err := uuid.FromString(idStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid command ID"})
return
}
original, err := h.commandQueries.GetCommandByID(id)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("failed to get original command: %v", err)})
return
}
if original.Status != "failed" && original.Status != "timed_out" && original.Status != "cancelled" {
c.JSON(http.StatusBadRequest, gin.H{"error": "command must be failed, timed_out, or cancelled to retry"})
return
}
newCommand, err := h.buildRetryCommand(original)
if err != nil {
log.Printf("[ERROR] [server] [updates] RetryCommand: build/sign failed for original=%s agent=%s: %v", id, original.AgentID, err)
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("failed to retry command: %v", err)})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "command retry created",
"command_id": newCommand.ID.String(),
"new_id": newCommand.ID.String(),
})
}
// GetUpdateLifecycleHistory returns the lifecycle transition history for the
// package behind an update, scoped to that update's agent. Backs the Lifecycle
// History panel on the update detail view.
// GET /updates/:id/lifecycle
func (h *UpdateHandler) GetUpdateLifecycleHistory(c *gin.Context) {
updateID, err := uuid.FromString(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid update ID"})
return
}
update, err := h.updateQueries.GetUpdateByID(updateID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "update not found"})
return
}
history, err := h.updateQueries.GetPackageHistory(update.AgentID, update.PackageType, update.PackageName, 100)
if err != nil {
log.Printf("[ERROR] [server] [updates] lifecycle_history_failed update=%s: %v", updateID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load lifecycle history"})
return
}
c.JSON(http.StatusOK, gin.H{
"history": history,
"package_type": update.PackageType,
"package_name": update.PackageName,
"count": len(history),
})
}
// ReopenUpdate moves a failed update back to pending for another lifecycle
// attempt, clearing the failure markers. The orchestrator re-evaluates it on
// the next discovery sweep (fired immediately when wired). The transition guard
// rejects anything not currently failed.
// POST /updates/:id/reopen
func (h *UpdateHandler) ReopenUpdate(c *gin.Context) {
updateID, err := uuid.FromString(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid update ID"})
return
}
if err := h.updateQueries.ReopenFailedUpdate(updateID); err != nil {
log.Printf("[WARNING] [server] [updates] reopen_failed update=%s: %v", updateID, err)
c.JSON(http.StatusConflict, gin.H{"error": fmt.Sprintf("cannot reopen update: %v", err)})
return
}
if h.orchestrator != nil {
h.orchestrator.OnPackagesDiscovered()
}
log.Printf("[INFO] [server] [updates] update_reopened update=%s", updateID)
c.JSON(http.StatusOK, gin.H{"message": "update reopened"})
}
// ResolveUpdate closes a failed update as installed — used when the update no
// longer applies (already current, resolved out of band) so it leaves the
// actionable list. The transition guard rejects anything not currently failed.
// POST /updates/:id/resolve
func (h *UpdateHandler) ResolveUpdate(c *gin.Context) {
updateID, err := uuid.FromString(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid update ID"})
return
}
if err := h.updateQueries.ResolveFailedUpdate(updateID); err != nil {
log.Printf("[WARNING] [server] [updates] resolve_failed update=%s: %v", updateID, err)
c.JSON(http.StatusConflict, gin.H{"error": fmt.Sprintf("cannot resolve update: %v", err)})
return
}
log.Printf("[INFO] [server] [updates] update_resolved update=%s", updateID)
c.JSON(http.StatusOK, gin.H{"message": "update resolved as installed"})
}
// CancelCommand cancels a pending or sent command
func (h *UpdateHandler) CancelCommand(c *gin.Context) {
idStr := c.Param("id")
id, err := uuid.FromString(idStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid command ID"})
return
}
// Capture the command before cancelling so the audit event carries agent + type.
command, _ := h.commandQueries.GetCommandByID(id)
if err := h.commandQueries.CancelCommand(id); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("failed to cancel command: %v", err)})
return
}
// ETHOS #1: cancellation is an operator-driven state change against an agent's
// command — journal it inward so it surfaces in the events API / history.
if command != nil {
event := &models.SystemEvent{
ID: uuid.Must(uuid.NewV4()),
AgentID: &command.AgentID,
EventType: models.EventTypeCommandFailed,
EventSubtype: "cancelled",
Severity: models.SeverityWarning,
Component: models.ComponentServer,
Message: fmt.Sprintf("Command %s (%s) cancelled by operator", command.CommandType, command.ID),
Metadata: map[string]interface{}{
"command_id": command.ID.String(),
"command_type": command.CommandType,
"prior_status": command.Status,
},
CreatedAt: time.Now().UTC(),
}
if err := h.agentQueries.CreateSystemEvent(event); err != nil {
log.Printf("[WARNING] [server] [updates] system_event_write_failed command_id=%s error=%v", command.ID, err)
}
}
c.JSON(http.StatusOK, gin.H{"message": "command cancelled"})
}
// GetActiveCommands retrieves currently active commands for live operations view
func (h *UpdateHandler) GetActiveCommands(c *gin.Context) {
commands, err := h.commandQueries.GetActiveCommands()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to retrieve active commands"})
return
}
c.JSON(http.StatusOK, gin.H{
"commands": commands,
"count": len(commands),
})
}
// GetCommandByID retrieves a single command by ID. Used by the UI to poll
// for the result of a capture_screenshot command.
func (h *UpdateHandler) GetCommandByID(c *gin.Context) {
commandID, err := uuid.FromString(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid command ID"})
return
}
command, err := h.commandQueries.GetCommandByID(commandID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "command not found"})
return
}
c.JSON(http.StatusOK, command)
}
// GetRecentCommands retrieves recent commands for retry functionality
func (h *UpdateHandler) GetRecentCommands(c *gin.Context) {
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
commands, err := h.commandQueries.GetRecentCommands(limit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to retrieve recent commands"})
return
}
c.JSON(http.StatusOK, gin.H{
"commands": commands,
"count": len(commands),
"limit": limit,
})
}
// ClearFailedCommands manually removes failed/timed_out commands with cheeky warning
func (h *UpdateHandler) ClearFailedCommands(c *gin.Context) {
// Get query parameters for filtering
olderThanDaysStr := c.Query("older_than_days")
onlyRetriedStr := c.Query("only_retried")
allFailedStr := c.Query("all_failed")
var count int64
var err error
// Parse parameters
olderThanDays := 7 // default
if olderThanDaysStr != "" {
if days, err := strconv.Atoi(olderThanDaysStr); err == nil && days > 0 {
olderThanDays = days
}
}
onlyRetried := onlyRetriedStr == "true"
allFailed := allFailedStr == "true"
// Build the appropriate cleanup query based on parameters
if allFailed {
// Clear ALL failed commands regardless of age (most aggressive)
count, err = h.commandQueries.ClearAllFailedCommandsRegardlessOfAge()
} else if onlyRetried {
// Clear only failed commands that have been retried
count, err = h.commandQueries.ClearRetriedFailedCommands(olderThanDays)
} else {
// Clear failed commands older than specified days (default behavior)
count, err = h.commandQueries.ClearOldFailedCommands(olderThanDays)
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "failed to clear failed commands",
"details": err.Error(),
})
return
}
// Return success with cheeky message
message := fmt.Sprintf("Archived %d failed commands", count)
if count > 0 {
message += ". WARNING: This shouldn't be necessary if the retry logic is working properly - you might want to check what's causing commands to fail in the first place!"
message += " (History preserved - commands moved to archived status)"
} else {
message += ". No failed commands found matching your criteria. SUCCESS!"
}
c.JSON(http.StatusOK, gin.H{
"message": message,
"count": count,
"cheeky_warning": "Consider this a developer experience enhancement - the system should clean up after itself automatically!",
})
}
// GetExpectedHash returns the expected SHA256 hash for a package (Layer 1: Hash Registry)
// The agent calls this to verify packages before installation
func (h *UpdateHandler) GetExpectedHash(c *gin.Context) {
packageType := c.Query("package_type")
packageName := c.Query("package_name")
agentIDStr := c.Query("agent_id")
if packageType == "" || packageName == "" || agentIDStr == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "package_type, package_name, and agent_id are required"})
return
}
agentID, err := uuid.FromString(agentIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid agent_id"})
return
}
update, err := h.updateQueries.GetUpdateByPackage(agentID, packageType, packageName)
if err != nil {
if err.Error() == "sql: no rows" {
c.JSON(http.StatusNotFound, gin.H{"error": "update not found for this package"})
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to lookup update"})
}
return
}
sha, err := h.updateQueries.GetExpectedSHA256(update.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to retrieve hash"})
return
}
if sha == "" {
c.JSON(http.StatusNotFound, gin.H{"error": "no expected hash stored for this update"})
return
}
c.JSON(http.StatusOK, gin.H{
"package_type": packageType,
"package_name": packageName,
"expected_sha256": sha,
})
}
// GetCapabilityTokens delivers an agent's minted-but-undelivered capability
// tokens. The agent passes each to the privileged executor, which verifies the
// signature and artifact hashes independently before performing the operation.
// Tokens are marked delivered as they are served; the executor's local replay
// guard — not delivery state — is the authority on single use.
func (h *UpdateHandler) GetCapabilityTokens(c *gin.Context) {
if h.tokenQueries == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "capability gate not enabled"})
return
}
agentID := c.MustGet("agent_id").(uuid.UUID)
ids, err := h.tokenQueries.ListUndeliveredForAgent(agentID, time.Now().UTC().Unix())
if err != nil {
log.Printf("[ERROR] [server] [capability] list_undelivered_failed agent_id=%s error=%v", agentID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list capability tokens"})
return
}
tokens := make([]*capability.Token, 0, len(ids))
for _, id := range ids {
token, err := h.tokenQueries.GetByID(id)
if err != nil {
log.Printf("[ERROR] [server] [capability] token_load_failed token_id=%s error=%v", id, err)
continue
}
tokens = append(tokens, token)
if err := h.tokenQueries.MarkDelivered(id); err != nil {
log.Printf("[WARNING] [server] [capability] mark_delivered_failed token_id=%s error=%v", id, err)
}
}
c.JSON(http.StatusOK, gin.H{"tokens": tokens})
}
// GetCapabilityTokenStatus returns token lifecycle history for an update.
// Authenticated via WebAuthMiddleware (dashboard), not agent auth.
// Returns 404 when no tokens exist for the given (agent_id, update_id) pair.
func (h *UpdateHandler) GetCapabilityTokenStatus(c *gin.Context) {
if h.tokenQueries == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "capability gate not enabled"})
return
}
agentIDStr := c.Query("agent_id")
updateIDStr := c.Query("update_id")
if agentIDStr == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "agent_id query parameter is required"})
return
}
agentID, err := uuid.FromString(agentIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid agent_id"})
return
}
var updateID uuid.UUID
if updateIDStr != "" {
updateID, err = uuid.FromString(updateIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid update_id"})
return
}
}
history, err := h.tokenQueries.GetTokenHistory(agentID, updateID)
if err != nil {
log.Printf("[ERROR] [server] [capability] token_history_query_failed agent_id=%s update_id=%s error=%v",
agentID, updateID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to query token history"})
return
}
activeCount, err := h.tokenQueries.GetActiveTokenCount()
if err != nil {
log.Printf("[ERROR] [server] [capability] active_token_count_failed error=%v", err)
// Non-fatal: return what we have
activeCount = 0
}
type tokenRow struct {
queries.TokenStatusHistory
Status string `json:"status"`
}
out := make([]tokenRow, 0, len(history))
for _, t := range history {
s := t.Status()
out = append(out, tokenRow{TokenStatusHistory: t, Status: s})
}
c.JSON(http.StatusOK, gin.H{
"tokens": out,
"count": len(out),
"active_count": activeCount,
})
}
// ReportCapabilityResult records the executor's outcome for a delivered token.
// This is an audit receipt: the executor's local replay guard is authoritative on
// single use, so a receipt only marks the server-side consumed_at and logs the
// decision. Tokens are bound to the calling agent; an agent cannot post a receipt
// for a token that is not its own.
// Idempotency: duplicate receipt is rejected with 409 Conflict (ETHOS #4).
func (h *UpdateHandler) ReportCapabilityResult(c *gin.Context) {
if h.tokenQueries == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "capability gate not enabled"})
return
}
agentID := c.MustGet("agent_id").(uuid.UUID)
tokenID, err := uuid.FromString(c.Param("token_id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid token_id"})
return
}
var body struct {
Decision string `json:"decision"`
Reason string `json:"reason"`
ExitCode int `json:"exit_code"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid receipt body"})
return
}
token, err := h.tokenQueries.GetByID(tokenID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "token not found"})
return
}
if token.AgentID != agentID.String() {
log.Printf("[SECURITY] [server] [capability] receipt_agent_mismatch token_id=%s token_agent=%s caller_agent=%s",
tokenID, token.AgentID, agentID)
c.JSON(http.StatusForbidden, gin.H{"error": "token not bound to this agent"})
return
}
// Idempotency guard: reject duplicate receipt (ETHOS #4).
if consumed, err := h.tokenQueries.IsConsumed(tokenID); err != nil {
log.Printf("[ERROR] [server] [capability] receipt_consumed_check_failed token_id=%s error=%v", tokenID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to check token state"})
return
} else if consumed {
log.Printf("[INFO] [server] [capability] duplicate_receipt_rejected token_id=%s agent_id=%s", tokenID, agentID)
c.JSON(http.StatusConflict, gin.H{"error": "duplicate receipt — token already consumed"})
return
}
if _, err := h.tokenQueries.MarkConsumed(tokenID); err != nil {
log.Printf("[ERROR] [server] [capability] mark_consumed_failed token_id=%s error=%v", tokenID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to record receipt"})
return
}
if updateID, ok, err := h.tokenQueries.GetUpdateIDForToken(tokenID); err != nil {
log.Printf("[ERROR] [server] [capability] receipt_update_lookup_failed token_id=%s error=%v", tokenID, err)
} else if ok {
if update, err := h.updateQueries.GetUpdateByID(updateID); err != nil {
log.Printf("[ERROR] [server] [capability] receipt_update_load_failed token_id=%s update_id=%s error=%v",
tokenID, updateID, err)
} else {
status := models.StatusFailed
if body.Decision == "executed" && body.ExitCode == 0 {
status = models.StatusInstalled
}
metadata := models.JSONB{
"capability_token_id": tokenID.String(),
"capability_decision": body.Decision,
"capability_reason": body.Reason,
"capability_exit_code": body.ExitCode,
}
completedAt := time.Now().UTC()
if err := h.updateQueries.UpdatePackageStatus(update.AgentID, update.PackageType, update.PackageName, status, metadata, &completedAt); err != nil {
log.Printf("[ERROR] [server] [capability] receipt_status_update_failed token_id=%s update_id=%s status=%s error=%v",
tokenID, updateID, status, err)
} else {
log.Printf("[INFO] [server] [capability] receipt_status_updated token_id=%s update_id=%s status=%s",
tokenID, updateID, status)
// Pin the installed version so subsequent scans don't
// silently advance to a newer unapproved version.
if status == models.StatusInstalled {
installedVersion := update.AvailableVersion
if update.SelectedVersion != nil && *update.SelectedVersion != "" {
installedVersion = *update.SelectedVersion
}
if installedVersion != "" {
_ = h.updateQueries.SetInstalledVersionHold(updateID, installedVersion)
}
}
}
}
}
log.Printf("[SECURITY] [server] [capability] receipt token_id=%s agent_id=%s decision=%s reason=%s exit=%d",
tokenID, agentID, body.Decision, body.Reason, body.ExitCode)
c.JSON(http.StatusOK, gin.H{"message": "receipt recorded"})
}