mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 10:35:51 +00:00
f4c2fd0c38
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.
237 lines
7.7 KiB
Go
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
|
|
}
|