Watch
1
0
Fork
You've already forked RedFlag
0

supply chain gate: a vuln is a full stop now — admin signs for it or it doesn't ship

delivery plumbing that got us there:
- acks clear on result-recorded, not command lifecycle status (no more 34-deep recycling)
- timeouts, cancels, dropped acks/receipts, failed actions all land in history instead of dying on stdout
- one shared closure-cleared predicate so auto-confirm and manual approve can't drift

override waives the vuln call only and gets journaled; signing and hash verification stay non-negotiable.
This commit is contained in:
Fimeg 2026-06-01 09:55:22 -04:00
commit dc3bfdf493
9 changed files with 363 additions and 73 deletions

View file

@ -130,31 +130,40 @@ func (t *Tracker) IncrementRetry(commandID string) {
}
}
// Cleanup removes old or over-retried pending results
func (t *Tracker) Cleanup() int {
// DroppedResult describes a pending result-ack that Cleanup abandoned. The agent
// only ever redelivers the command ID (not the result payload), so a result the
// server never recorded can sit here unrecoverable until it ages out — and a drop
// is the silent loss of an auditable event. Cleanup returns these so the caller
// journals each one inward (ETHOS #1) rather than discarding it to /dev/null.
type DroppedResult struct {
CommandID string
Reason string // "max_age" | "max_retries"
RetryCount int
AgeSeconds int
}
// Cleanup removes old or over-retried pending results and returns what it dropped
// so the loss can be recorded as history. Returns an empty slice when nothing aged out.
func (t *Tracker) Cleanup() []DroppedResult {
t.mu.Lock()
defer t.mu.Unlock()
now := time.Now().UTC()
removed := 0
var dropped []DroppedResult
for id, result := range t.pending {
// Remove if too old
if now.Sub(result.SentAt) > t.maxAge {
age := now.Sub(result.SentAt)
switch {
case age > t.maxAge:
dropped = append(dropped, DroppedResult{CommandID: id, Reason: "max_age", RetryCount: result.RetryCount, AgeSeconds: int(age.Seconds())})
delete(t.pending, id)
removed++
continue
}
// Remove if retried too many times
if result.RetryCount >= t.maxRetries {
case result.RetryCount >= t.maxRetries:
dropped = append(dropped, DroppedResult{CommandID: id, Reason: "max_retries", RetryCount: result.RetryCount, AgeSeconds: int(age.Seconds())})
delete(t.pending, id)
removed++
continue
}
}
return removed
return dropped
}
// Stats returns statistics about pending acknowledgments

View file

@ -363,6 +363,48 @@ func RunPollingLoop(loopCtx *LoopContext) error {
}
}
// Bound the delivery trackers and journal whatever they abandon. A dropped
// result-ack or receipt is the silent loss of an auditable event — the agent
// only ever redelivers the command ID, never the payload, so a result the
// server never recorded is unrecoverable once it ages out. ETHOS #1: it
// becomes history, not a stdout line. BufferEvent persists to disk and flushes
// later, so the record survives the same network loss that caused the drop.
if dropped := ctx.AckTracker.Cleanup(); len(dropped) > 0 {
for _, d := range dropped {
log.Printf("[WARNING] [agent] [acknowledgment] result_ack_dropped command_id=%s reason=%s retries=%d age_s=%d",
d.CommandID, d.Reason, d.RetryCount, d.AgeSeconds)
ctx.APIClient.BufferEvent(models.EventTypeError, models.SubtypeFailed, models.SeverityWarning,
models.ComponentAgent,
fmt.Sprintf("Abandoned delivery of command result %s (%s) — server never confirmed receipt", d.CommandID, d.Reason),
map[string]interface{}{
"kind": "result_ack",
"command_id": d.CommandID,
"reason": d.Reason,
"retry_count": d.RetryCount,
"age_seconds": d.AgeSeconds,
})
}
if err := ctx.AckTracker.Save(); err != nil {
log.Printf("[ERROR] [agent] [acknowledgment] save_failed error=%v", err)
}
}
if dropped := ctx.ReceiptTracker.Cleanup(); len(dropped) > 0 {
for _, d := range dropped {
log.Printf("[WARNING] [agent] [receipt] receipt_dropped command_id=%s age_s=%d", d.CommandID, d.AgeSeconds)
ctx.APIClient.BufferEvent(models.EventTypeError, models.SubtypeFailed, models.SeverityWarning,
models.ComponentAgent,
fmt.Sprintf("Abandoned receipt confirmation for command %s — server never acknowledged receipt before max-age", d.CommandID),
map[string]interface{}{
"kind": "receipt",
"command_id": d.CommandID,
"age_seconds": d.AgeSeconds,
})
}
if err := ctx.ReceiptTracker.Save(); err != nil {
log.Printf("[ERROR] [agent] [receipt] save_failed error=%v", err)
}
}
// Drop confirmed completions the server acknowledged via ReportLog.
// This is the key fix for duplicate command rejections: if the server
// has confirmed a command as completed, the agent should NOT reject it

View file

@ -126,22 +126,32 @@ func (t *Tracker) Confirm(commandIDs []string) {
}
}
// Cleanup discards receipts older than maxAge. Returns the count removed. Should be
// run periodically to bound disk and memory growth in the pathological case where
// the server forgets a command id permanently.
func (t *Tracker) Cleanup() int {
// DroppedReceipt describes a receipt confirmation abandoned by Cleanup: the agent
// received a command but the server never confirmed receipt, and the entry aged
// out. A drop means the command's lifecycle silently diverged between the two
// sides — the caller journals it inward (ETHOS #1) rather than discarding it.
type DroppedReceipt struct {
CommandID string
AgeSeconds int
}
// Cleanup discards receipts older than maxAge and returns what it dropped so the
// loss can be recorded as history. Bounds disk/memory growth in the pathological
// case where the server forgets a command id permanently. Returns an empty slice
// when nothing aged out.
func (t *Tracker) Cleanup() []DroppedReceipt {
t.mu.Lock()
defer t.mu.Unlock()
now := time.Now().UTC()
removed := 0
var dropped []DroppedReceipt
for id, p := range t.pending {
if now.Sub(p.ReceivedAt) > t.maxAge {
if age := now.Sub(p.ReceivedAt); age > t.maxAge {
dropped = append(dropped, DroppedReceipt{CommandID: id, AgeSeconds: int(age.Seconds())})
delete(t.pending, id)
removed++
}
}
return removed
return dropped
}
// Len returns the current size of the pending set.

View file

@ -951,8 +951,9 @@ func (h *AgentHandler) GetCommands(c *gin.Context) {
}
log.Printf("DEBUG: Processing %d pending acknowledgments for agent %s: %v", len(metrics.PendingAcknowledgments), agentID, metrics.PendingAcknowledgments)
// Verify which commands from agent's pending list have been recorded
verified, err := h.commandQueries.VerifyCommandsCompleted(metrics.PendingAcknowledgments)
// Ack every command whose result the server has durably recorded, regardless
// of its lifecycle status — that receipt is all the agent's pending ack waits on.
verified, err := h.commandQueries.VerifyResultsRecorded(metrics.PendingAcknowledgments)
if err != nil {
log.Printf("Warning: Failed to verify command acknowledgments for agent %s: %v", agentID, err)
} else {

View file

@ -497,6 +497,77 @@ func (h *UpdateHandler) GetPackageVersions(c *gin.Context) {
})
}
// 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
}
// 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 (dnf/apt), a closure OSV never managed to check also
// blocks: minting over it would trust artifacts nothing vetted. Mirrors the
// auto-confirm gate (orchestrator.closureCleared) via the shared models predicates.
func (h *UpdateHandler) evaluateSupplyChainHold(update *models.UpdateState, freshVulns []services.VulnerabilityInfo) supplyChainHold {
if len(freshVulns) > 0 || models.MetadataHasVulns(*update, "supply_chain_vulns") {
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) {
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{}
}
// recordSupplyChainOverride journals an operator's deliberate decision to install
// past the supply-chain gate. ETHOS #1: the void is trusted only by a named human,
// on the record. The signed token minted afterward still binds the real artifact
// hashes — this records that the vulnerability/verification judgment was waived,
// not that authenticity was.
func (h *UpdateHandler) recordSupplyChainOverride(update *models.UpdateState, hold supplyChainHold, operatorReason string) {
subtype := "vuln_overridden"
if hold.unverified {
subtype = "unverified_overridden"
}
log.Printf("[SECURITY] [server] [supply_chain] gate_overridden id=%s pkg=%s version=%s cause=%q operator_reason=%q",
update.ID, update.PackageName, update.AvailableVersion, hold.reason, operatorReason)
event := &models.SystemEvent{
ID: uuid.New(),
AgentID: &update.AgentID,
EventType: "supply_chain_override",
EventSubtype: subtype,
Severity: models.SeverityWarning,
Component: models.ComponentSecurity,
Message: fmt.Sprintf("Operator overrode the supply-chain gate for %s %s and approved install anyway: %s",
update.PackageName, update.AvailableVersion, hold.reason),
Metadata: map[string]interface{}{
"package_name": update.PackageName,
"package_type": update.PackageType,
"version": update.AvailableVersion,
"cause": hold.reason,
"unverified": hold.unverified,
"operator_reason": operatorReason,
"approved_by": "admin",
},
CreatedAt: time.Now().UTC(),
}
if err := h.agentQueries.CreateSystemEvent(event); err != nil {
log.Printf("[WARNING] [server] [supply_chain] override_event_write_failed id=%s error=%v", update.ID, err)
}
}
// ApproveUpdate marks an update as approved, with OSV.dev supply chain check
func (h *UpdateHandler) ApproveUpdate(c *gin.Context) {
idStr := c.Param("id")
@ -506,6 +577,13 @@ func (h *UpdateHandler) ApproveUpdate(c *gin.Context) {
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 {
@ -570,6 +648,40 @@ func (h *UpdateHandler) ApproveUpdate(c *gin.Context) {
}
}
// 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, 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": update.AvailableVersion,
"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, hold, req.OverrideReason)
}
// 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 != "") {
@ -746,14 +858,20 @@ func (h *UpdateHandler) ReportLog(c *gin.Context) {
return
}
// Idempotency check: if command_id is provided and command is already
// completed/failed, reject duplicate submissions (ETHOS #4).
// 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.Parse(req.CommandID)
if err == nil {
command, err := h.commandQueries.GetCommandByID(commandID)
if err == nil && command != nil {
// Command already has a terminal status — reject duplicate
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)
@ -904,10 +1022,12 @@ func (h *UpdateHandler) ReportLog(c *gin.Context) {
}
}
// Emit a system_event for failed agent actions so the failure surfaces in the
// events API and dashboard. Best-effort — log and continue on failure, don't
// block the response.
if validResult == "failed" && (req.Action == "update_agent" || req.Action == "verify_command") {
// 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)
@ -916,11 +1036,18 @@ func (h *UpdateHandler) ReportLog(c *gin.Context) {
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.New(),
AgentID: &agentID,
EventType: "agent_update",
EventType: eventType,
EventSubtype: "failed",
Severity: "error",
Component: "agent",
@ -1124,6 +1251,21 @@ func (h *UpdateHandler) ApproveUpdates(c *gin.Context) {
}
}
// 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, 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
}
needsMeta := len(vulns) > 0 || (!ageDecision.Unknown && ageDecision.WarnMessage != "")
if needsMeta {
if err := h.updateQueries.ApproveUpdateWithVulns(id, "admin", update.Metadata); err != nil {
@ -1905,12 +2047,37 @@ func (h *UpdateHandler) CancelCommand(c *gin.Context) {
return
}
// Cancel the command
// 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.New(),
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"})
}

View file

@ -341,12 +341,16 @@ func (q *CommandQueries) GetLatestRetryableCommandByUpdateID(updateID uuid.UUID)
return &command, nil
}
// CancelCommand marks a command as cancelled
// CancelCommand marks a command as cancelled. It also records a result: a
// cancelled command is resolved server-side, so the agent's pending result-ack
// (which clears on result-recorded) should stop retrying rather than stranding
// until its 24h max-age. COALESCE preserves any result the agent already reported.
func (q *CommandQueries) CancelCommand(id uuid.UUID) error {
now := time.Now().UTC()
query := `
UPDATE agent_commands
SET status = 'cancelled', completed_at = $1
SET status = 'cancelled', completed_at = $1,
result = COALESCE(result, '{"cancelled": true, "reason": "operator cancelled command"}'::jsonb)
WHERE id = $2 AND status IN ('pending', 'sent')
`
_, err := q.db.Exec(query, now, id)
@ -660,9 +664,18 @@ func (q *CommandQueries) GetStuckCommands(agentID uuid.UUID, olderThan time.Dura
return commands, err
}
// VerifyCommandsCompleted checks which command IDs from the provided list have been completed or failed
// Returns the list of command IDs that have been successfully recorded (completed or failed status)
func (q *CommandQueries) VerifyCommandsCompleted(commandIDs []string) ([]string, error) {
// VerifyResultsRecorded returns the subset of the given command IDs whose result
// the server has durably stored. The pending ack exists for one thing: at-least-once
// delivery of the agent's result. Every ReportLog path writes that result onto the
// command row, so a non-null result is the receipt — once it's set, the agent can
// stop resending. A command with no result yet stays unacked and the agent keeps
// retrying, which is exactly what at-least-once requires.
//
// This deliberately does not key off command lifecycle status. Doing so stranded
// acks whenever a command settled into a status the agent's result didn't drive
// (progress reports, cancelled/archived rows) — they recycled every poll until the
// agent's 24h max-age finally dropped them.
func (q *CommandQueries) VerifyResultsRecorded(commandIDs []string) ([]string, error) {
if len(commandIDs) == 0 {
return []string{}, nil
}
@ -701,7 +714,7 @@ func (q *CommandQueries) VerifyCommandsCompleted(commandIDs []string) ([]string,
SELECT id
FROM agent_commands
WHERE id::text = ANY(%s)
AND status IN ('completed', 'failed', 'timed_out')
AND result IS NOT NULL
`, fmt.Sprintf("ARRAY[%s]", strings.Join(placeholders, ",")))
var completedUUIDs []uuid.UUID

View file

@ -0,0 +1,53 @@
package models
import "strings"
// Supply-chain gate predicates. These are the single source of truth for "is this
// package's supply-chain posture clean enough to mint a capability token over it?"
// Both the automatic confirmation sweep (orchestrator) and the manual operator
// approval path (handlers) call them, so the two enforcement points can never drift.
// MetadataHasVulns reports whether metadata[key] records a non-empty vulnerability
// set. An unexpected shape is treated as vulnerable, conservatively.
func MetadataHasVulns(pkg UpdateState, key string) bool {
if pkg.Metadata == nil {
return false
}
v, ok := pkg.Metadata[key]
if !ok || v == nil {
return false
}
switch t := v.(type) {
case []interface{}:
return len(t) > 0
case string:
s := strings.TrimSpace(t)
return s != "" && s != "[]" && s != "null"
default:
return true
}
}
// ClosureChecked reports whether the resolved dependency closure was OSV-checked at
// all (closure_checked_at present and non-empty). False means OSV could not vet the
// closure — the "trust the void" case the operator must consciously accept.
func ClosureChecked(pkg UpdateState) bool {
if pkg.Metadata == nil {
return false
}
v, ok := pkg.Metadata["closure_checked_at"]
if !ok || v == nil {
return false
}
if s, isStr := v.(string); isStr && strings.TrimSpace(s) == "" {
return false
}
return true
}
// ClosureCleared reports whether the resolved dependency closure was OSV-checked
// AND came back clean. A token is auto-minted over the closure only when this holds;
// the manual path requires an explicit operator override to proceed without it.
func ClosureCleared(pkg UpdateState) bool {
return ClosureChecked(pkg) && !MetadataHasVulns(pkg, "closure_vulns")
}

View file

@ -67,30 +67,10 @@ func shouldAutoApprove(pkg models.UpdateState, maxSeverity string) bool {
// hasBlockingVulns reports whether the package metadata records supply-chain
// vulnerabilities on the top-level package. Auto-approval defers to the operator
// whenever vulns are present, regardless of the severity ceiling — the vuln
// review is a human call.
// review is a human call. Delegates to the shared gate predicate so auto-confirm
// and the manual approval path enforce the same rule.
func hasBlockingVulns(pkg models.UpdateState) bool {
return hasVulns(pkg, "supply_chain_vulns")
}
// hasVulns reports whether metadata[key] records a non-empty vulnerability set.
// An unexpected shape is treated as vulnerable, conservatively.
func hasVulns(pkg models.UpdateState, key string) bool {
if pkg.Metadata == nil {
return false
}
v, ok := pkg.Metadata[key]
if !ok || v == nil {
return false
}
switch t := v.(type) {
case []interface{}:
return len(t) > 0
case string:
s := strings.TrimSpace(t)
return s != "" && s != "[]" && s != "null"
default:
return true
}
return models.MetadataHasVulns(pkg, "supply_chain_vulns")
}
// closureCleared reports whether the resolved dependency closure has been
@ -100,15 +80,5 @@ func hasVulns(pkg models.UpdateState, key string) bool {
// token is never minted over transitive artifacts OSV did not vet. A closure
// carrying vulns is left for an operator regardless of the severity ceiling.
func closureCleared(pkg models.UpdateState) bool {
if pkg.Metadata == nil {
return false
}
v, ok := pkg.Metadata["closure_checked_at"]
if !ok || v == nil {
return false
}
if s, isStr := v.(string); isStr && strings.TrimSpace(s) == "" {
return false
}
return !hasVulns(pkg, "closure_vulns")
return models.ClosureCleared(pkg)
}

View file

@ -210,6 +210,31 @@ func (ts *TimeoutService) timeoutCommand(command *models.AgentCommand) error {
// Don't return error here as the main timeout operation succeeded
}
// ETHOS #1: a timed-out command is an auditable delivery failure. update_logs
// alone does not surface in the events API / history pane (reconcileAgentUpdates
// in this same service writes system_events; this path must too). Best-effort.
if ts.agentQueries != nil {
event := &models.SystemEvent{
ID: uuid.New(),
AgentID: &command.AgentID,
EventType: models.EventTypeCommandFailed,
EventSubtype: "timed_out",
Severity: models.SeverityWarning,
Component: models.ComponentServer,
Message: fmt.Sprintf("Command %s (%s) timed out after %v with no agent result", command.CommandType, command.ID, appliedTimeout),
Metadata: map[string]interface{}{
"command_id": command.ID.String(),
"command_type": command.CommandType,
"prior_status": command.Status,
"timeout": appliedTimeout.String(),
},
CreatedAt: time.Now().UTC(),
}
if err := ts.agentQueries.CreateSystemEvent(event); err != nil {
log.Printf("[WARNING] [server] [timeout] system_event_write_failed command_id=%s error=%v", command.ID, err)
}
}
log.Printf("Successfully timed out command %s", command.ID)
return nil
}