Files
pulse/internal/api/agent_activity_telemetry.go
T
rcourtman f4c2fd0c38 Fail closed on unknown remediation lock state for autonomous dispatches
The AI action broker treated an unreadable operator lock as unlocked:
isResourceRemediationLocked returned (false, nil) with no audit store
wired, and the caller logged store errors then dispatched anyway. An
operator's NeverAutoRemediate=true could be silently ignored whenever
the policy store was missing or erroring, which is unacceptable while
Patrol and Assistant run at assisted or full autonomy.

Posture change at the dispatch decision point:
- isResourceRemediationLocked now reports unknown state (nil store or
  lookup failure) as an ErrRemediationLockStateUnknown-wrapped error
  instead of silently defaulting to unlocked.
- New checkRemediationLockForDispatch gate: dispatches without an
  approved human decision fail CLOSED on unknown lock state and
  surface "remediation lock state unknown; operator approval
  required". Human-approved dispatches keep the historical fail-open
  behavior with a warning log. A confirmed lock still refuses even
  approved dispatches, as before.
- executeNativeActionWithAudit (TrueNAS app start/stop/restart) now
  enforces the lock too; it previously skipped the check entirely.
- Refusals persist Failed audit records with stable
  remediation_lock_state_unknown: / resource_remediation_locked:
  ErrorMessage prefixes.
- ai-runtime subsystem contract updated to pin the new posture.

Tests cover store-error and nil-store at both autonomy postures on
both dispatch paths; routing/control tests now wire an in-memory
audit store since autonomous dispatch without one is refused.
2026-07-10 00:14:01 +01:00

237 lines
7.7 KiB
Go

package api
import (
"encoding/json"
"io"
"net/http"
"strings"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/agentcapabilities"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rs/zerolog/log"
)
func (r *Router) withExternalAgentCapabilityActivity(capabilityName string, handler http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
r.recordExternalAgentCapabilityActivity(req, capabilityName)
handler(w, req)
}
}
func (r *Router) recordExternalAgentCapabilityActivity(req *http.Request, capabilityName string) {
activity, requiredScope, ok := externalAgentCapabilityActivity(capabilityName)
if !ok {
return
}
r.recordExternalAgentActivity(req, activity, requiredScope)
}
func externalAgentActivityForCapability(capabilityName string) (string, bool) {
activity, _, ok := externalAgentCapabilityActivity(capabilityName)
return activity, ok
}
func externalAgentCapabilityActivity(capabilityName string) (string, string, bool) {
capabilityName = strings.TrimSpace(capabilityName)
if capabilityName == "" {
return "", "", false
}
capability, ok := agentcapabilities.FindCapability(agentcapabilities.CanonicalManifest().Capabilities, capabilityName)
if !ok {
return "", "", false
}
var activity string
switch strings.TrimSpace(capabilityName) {
case agentcapabilities.ResourceContextCapabilityName,
agentcapabilities.ListResourceCapabilitiesCapabilityName:
activity = config.ExternalAgentActivityResourceContext
case agentcapabilities.FleetContextCapabilityName:
activity = config.ExternalAgentActivityFleetContext
case agentcapabilities.OperationsLoopStatusCapabilityName:
activity = config.ExternalAgentActivityFleetContext
case agentcapabilities.EventSubscriptionCapabilityName:
activity = config.ExternalAgentActivityEventStream
case agentcapabilities.ListNodesCapabilityName,
agentcapabilities.AddNodeCapabilityName,
agentcapabilities.UpdateNodeCapabilityName,
agentcapabilities.RemoveNodeCapabilityName,
agentcapabilities.TestNodeCredentialsCapabilityName,
agentcapabilities.TestNodeConnectionCapabilityName,
agentcapabilities.RefreshNodeClusterMembershipCapabilityName,
agentcapabilities.DiscoverLANCapabilityName:
activity = config.ExternalAgentActivityProvisioning
case agentcapabilities.GetOperatorStateCapabilityName,
agentcapabilities.SetOperatorStateCapabilityName,
agentcapabilities.ClearOperatorStateCapabilityName:
activity = config.ExternalAgentActivityOperatorState
case agentcapabilities.ListFindingsCapabilityName:
activity = config.ExternalAgentActivityFindingList
case agentcapabilities.AcknowledgeFindingCapabilityName,
agentcapabilities.SnoozeFindingCapabilityName,
agentcapabilities.DismissFindingCapabilityName,
agentcapabilities.ResolveFindingCapabilityName:
activity = config.ExternalAgentActivityFindingDecision
case agentcapabilities.PlanActionCapabilityName:
activity = config.ExternalAgentActivityActionPlan
case agentcapabilities.DecideActionCapabilityName:
activity = config.ExternalAgentActivityActionDecision
case agentcapabilities.ExecuteActionCapabilityName:
activity = config.ExternalAgentActivityActionExecute
default:
return "", "", false
}
requiredScope := strings.TrimSpace(capability.Scope)
if requiredScope == "" {
return "", "", false
}
return activity, requiredScope, true
}
func (r *Router) recordExternalAgentActivity(req *http.Request, activity string, requiredScopes ...string) {
if r == nil || req == nil || strings.TrimSpace(activity) == "" {
return
}
token := getAPITokenRecordFromRequest(req)
if !apiTokenCoversExternalAgentSurface(token, time.Now().UTC(), requiredScopes...) {
return
}
persistence := r.persistenceForOrg(req.Context())
if persistence == nil {
return
}
if err := persistence.RecordExternalAgentActivity(config.ExternalAgentActivityRecord{
Timestamp: time.Now().UTC(),
Surface: externalAgentActivitySurface(req),
Activity: activity,
}); err != nil {
log.Debug().Err(err).Str("activity", activity).Msg("Failed to record external agent activity")
}
}
type agentWorkflowPromptActivityRequest struct {
Name string `json:"name"`
}
// HandleAgentWorkflowPromptActivity records content-free workflow starter
// usage from agent adapters that render prompts locally, such as pulse-mcp.
func (r *Router) HandleAgentWorkflowPromptActivity(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var payload agentWorkflowPromptActivityRequest
dec := json.NewDecoder(http.MaxBytesReader(w, req.Body, 4096))
if err := dec.Decode(&payload); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
var discard json.RawMessage
if err := dec.Decode(&discard); err != io.EOF {
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
promptName := strings.TrimSpace(payload.Name)
if !canonicalWorkflowPromptDeclared(promptName) {
http.Error(w, "Unknown workflow prompt", http.StatusBadRequest)
return
}
r.recordWorkflowPromptActivity(req, workflowPromptActivitySurface(req), promptName)
w.WriteHeader(http.StatusNoContent)
}
func canonicalWorkflowPromptDeclared(promptName string) bool {
promptName = strings.TrimSpace(promptName)
if promptName == "" {
return false
}
for _, prompt := range agentcapabilities.ManifestPulseWorkflowPrompts(agentcapabilities.CanonicalManifest()) {
if strings.TrimSpace(prompt.Name) == promptName {
return true
}
}
return false
}
func (r *Router) recordWorkflowPromptActivity(req *http.Request, surface, promptName string) {
if r == nil || req == nil || strings.TrimSpace(promptName) == "" {
return
}
token := getAPITokenRecordFromRequest(req)
if !apiTokenCoversExternalAgentSurface(token, time.Now().UTC(), config.ScopeMonitoringRead) {
return
}
persistence := r.persistenceForOrg(req.Context())
if persistence == nil {
return
}
if err := persistence.RecordWorkflowPromptActivity(config.WorkflowPromptActivityRecord{
Timestamp: time.Now().UTC(),
Surface: surface,
PromptName: promptName,
}); err != nil {
log.Debug().Err(err).Str("prompt_name", promptName).Msg("Failed to record workflow prompt activity")
}
}
func workflowPromptActivitySurface(req *http.Request) string {
if req == nil {
return config.WorkflowPromptActivitySurfaceAgentAPI
}
switch strings.TrimSpace(req.Header.Get(agentcapabilities.AgentSurfaceHeader)) {
case agentcapabilities.AgentSurfacePulseMCP:
return config.WorkflowPromptActivitySurfacePulseMCP
default:
return config.WorkflowPromptActivitySurfaceAgentAPI
}
}
func externalAgentActivitySurface(req *http.Request) string {
if req == nil {
return config.ExternalAgentActivitySurfaceAgentAPI
}
switch strings.TrimSpace(req.Header.Get(agentcapabilities.AgentSurfaceHeader)) {
case agentcapabilities.AgentSurfacePulseMCP:
return config.ExternalAgentActivitySurfacePulseMCP
default:
return config.ExternalAgentActivitySurfaceAgentAPI
}
}
func apiTokenCoversExternalAgentSurface(token *config.APITokenRecord, now time.Time, requiredScopes ...string) bool {
if token == nil {
return false
}
if token.ExpiresAt != nil && now.After(token.ExpiresAt.UTC()) {
return false
}
if len(requiredScopes) == 0 {
requiredScopes = agentcapabilities.CanonicalManifest().RequiredScopes
}
if len(requiredScopes) == 0 {
return false
}
token = cloneAPITokenRecord(token)
for _, scope := range requiredScopes {
scope = strings.TrimSpace(scope)
if scope == "" {
return false
}
if !token.HasScope(scope) {
return false
}
}
return true
}
func cloneAPITokenRecord(token *config.APITokenRecord) *config.APITokenRecord {
if token == nil {
return nil
}
clone := token.Clone()
return &clone
}