Recover live Patrol approvals for Assistant handoffs

This commit is contained in:
rcourtman
2026-05-07 00:31:44 +01:00
parent 990f9fbc21
commit b8bad16d80
8 changed files with 227 additions and 41 deletions
@@ -134,7 +134,10 @@ lifecycle evidence, and retry/idempotency handling must not create duplicate
lifecycle events. Approval or rejection decisions for those plans must flow
through `POST /api/actions/{id}/decision`, which records API-owned audit and
lifecycle evidence only; lifecycle surfaces must not treat approval as
implicit command execution or define a parallel execution handoff. When a
implicit command execution or define a parallel execution handoff. Assistant
handoffs that recover a live Patrol approval by finding ID are still AI/runtime
review context only; agent lifecycle surfaces must not treat that recovered
approval reference as an agent command grant or host-execution shortcut. When a
planned resource capability is actually executed from an agent-lifecycle
surface, that handoff must route through `POST /api/actions/{id}/execute` so
the API-owned action audit records `executing` before dispatch and the
@@ -241,13 +241,15 @@ runtime cost control, and shared AI transport surfaces.
Those timeline facts remain read-only explanation context and do not grant
action authority. The runtime may also persist structured pending-action and
approval references from the same investigation record as
model-context metadata, but those references are review context only: they
must not include raw command text, must not grant approval or execution
authority, and must route any operator decision back through the governed
approval/remediation flow. When
those references include an approval ID, Assistant runtime may refresh a
current status snapshot from the canonical approval store on each turn, but
it must enforce org scoping and still omit the approval command payload.
model-context metadata, and the API handoff builder may recover the current
live Patrol investigation-fix approval by finding ID when the durable record
does not yet carry the latest approval ID. Those references are review
context only: they must not include raw command text, must not grant
approval or execution authority, and must route any operator decision back
through the governed approval/remediation flow. When those references include
an approval ID, Assistant runtime may refresh a current status snapshot from
the canonical approval store on each turn, but it must enforce org scoping
and still omit the approval command payload.
When those references resolve to a governed action plan or action audit,
Assistant runtime must hydrate the canonical action ID, lifecycle state,
requester, capability, approval policy, plan expiry, preflight/dry-run
@@ -316,11 +316,15 @@ the canonical monitored-system blocked payload.
Context]`, structured handoff resources, related root-cause/correlation
finding context, and structured handoff actions model-only, with the
briefing summarizing operator next steps, latest lifecycle event, and
governed action posture without raw command text. Related finding context
must resolve from the current unified finding store, stay bounded and
deduplicated, include current recency and latest lifecycle facts, and seed
only structured handoff resources for canonical policy, state, topology,
and timeline hydration. Chat execution owns
governed action posture without raw command text. Structured handoff action
references may use the current live Patrol investigation-fix approval for
the finding when that approval is newer than the approval ID on the durable
record, but the payload may carry only IDs, status/risk/target metadata, and
fix/action references, never the approval command payload. Related finding
context must resolve from the current unified finding store, stay bounded
and deduplicated, include current recency and latest lifecycle facts, and
seed only structured handoff resources for canonical policy, state,
topology, and timeline hydration. Chat execution owns
resource-policy sanitization of the assembled model-only handoff before
prompt injection, so API payload builders may pass structured product
context without turning raw resource identity into user-authored text or
@@ -830,12 +834,14 @@ the canonical monitored-system blocked payload.
explanation data and must not become saved user text or action authority. The
backend may also carry structured
pending-action and approval references from the investigation record into chat
execution, but those references must omit raw proposed-fix commands, remain
model-only review context, and leave approval/execution authority with the
governed approval and remediation APIs. Chat execution may refresh approval
status snapshots for those references from the canonical approval store, but
that snapshot is read-only, org-scoped, and must not expose or infer the raw
command. When the reference resolves to a governed action plan or action
execution, and may recover the current live Patrol investigation-fix approval
for the finding when the durable record has no current approval ID, but those
references must omit raw proposed-fix commands, remain model-only review
context, and leave approval/execution authority with the governed approval
and remediation APIs. Chat execution may refresh approval status snapshots for
those references from the canonical approval store, but that snapshot is
read-only, org-scoped, and must not expose or infer the raw command. When the
reference resolves to a governed action plan or action
audit, chat execution must hydrate the canonical action ID, lifecycle state,
requester, capability, approval policy, plan expiry, preflight/dry-run
summary, and terminal success/failure state from the action-audit store so
@@ -107,13 +107,16 @@ Patrol-specific presentation helpers.
only structured action references such as approval ID, fix ID, risk, target,
and resource identity; approval and execution authority stays with the
governed approval/remediation surfaces. Assistant may refresh the referenced
approval's current status for review, but Patrol presentation must still keep
command payloads inside governed approval/remediation context rather than
rendering them as handoff copy. Assistant may also hydrate the referenced
action plan or action audit so the handoff explains current action lifecycle
state, requester, capability, approval policy, plan expiry, preflight/dry-run
posture, and terminal success/failure without treating approval as execution
authority or exposing raw command/execution payloads. Assistant may also
approval's current status for review, and the backend handoff builder may
recover a live pending Patrol investigation-fix approval by finding ID when
the durable record lacks the current approval reference, but Patrol
presentation must still keep command payloads inside governed
approval/remediation context rather than rendering them as handoff copy.
Assistant may also hydrate the referenced action plan or action audit so the
handoff explains current action lifecycle state, requester, capability,
approval policy, plan expiry, preflight/dry-run posture, and terminal
success/failure without treating approval as execution authority or exposing
raw command/execution payloads. Assistant may also
enrich that same handoff with refreshed unified finding and
investigation-record state, canonical resource-policy guidance, current
canonical resource-state and capability context, canonical
@@ -424,7 +424,9 @@ bypass the API fail-closed execution gate.
finding no longer resolves is adjacent AI/runtime invalidation, not a
recovery freshness or restore-support decision. Structured action or
approval references carried by that handoff are also adjacent AI/runtime
review metadata only. Unified finding lifecycle facts, latest lifecycle
review metadata only, including when Assistant recovers the current live
Patrol approval by finding ID before building model-only action context.
Unified finding lifecycle facts, latest lifecycle
event briefing lines, and detailed lifecycle context carried by the same
handoff remain Patrol/AI review metadata and must not become backup recency,
restore support, or storage-local lifecycle state. Primary finding
+74 -13
View File
@@ -1646,29 +1646,50 @@ func buildUnifiedFindingHandoffResources(f *unified.UnifiedFinding, lookup unifi
return resources
}
func buildUnifiedFindingHandoffActions(f *unified.UnifiedFinding) []chat.HandoffAction {
if f == nil || f.InvestigationRecord == nil {
func buildUnifiedFindingHandoffActions(f *unified.UnifiedFinding, orgID string) []chat.HandoffAction {
if f == nil {
return nil
}
rec := f.InvestigationRecord
if strings.TrimSpace(rec.ApprovalID) == "" && rec.ProposedFix == nil {
liveApproval := livePatrolApprovalForFinding(f.ID, orgID)
if rec == nil && liveApproval == nil {
return nil
}
action := chat.HandoffAction{
FindingID: f.ID,
RecordID: rec.ID,
ApprovalID: rec.ApprovalID,
TargetResourceID: rec.Subject.ResourceID,
TargetResourceName: rec.Subject.ResourceName,
TargetResourceType: rec.Subject.ResourceType,
TargetNode: rec.Subject.Node,
TargetResourceID: f.ResourceID,
TargetResourceName: f.ResourceName,
TargetResourceType: f.ResourceType,
TargetNode: f.Node,
}
if rec.ProposedFix != nil {
if rec != nil {
action.RecordID = rec.ID
action.ApprovalID = rec.ApprovalID
action.TargetResourceID = firstNonEmptyString(rec.Subject.ResourceID, action.TargetResourceID)
action.TargetResourceName = firstNonEmptyString(rec.Subject.ResourceName, action.TargetResourceName)
action.TargetResourceType = firstNonEmptyString(rec.Subject.ResourceType, action.TargetResourceType)
action.TargetNode = firstNonEmptyString(rec.Subject.Node, action.TargetNode)
}
if liveApproval != nil {
action.ApprovalID = strings.TrimSpace(liveApproval.ID)
if risk := strings.TrimSpace(string(liveApproval.RiskLevel)); risk != "" {
action.RiskLevel = risk
}
if action.TargetResourceName == "" {
action.TargetResourceName = strings.TrimSpace(liveApproval.TargetName)
}
if action.TargetResourceType == "" && !strings.EqualFold(strings.TrimSpace(liveApproval.TargetID), strings.TrimSpace(f.ID)) {
action.TargetResourceType = strings.TrimSpace(liveApproval.TargetType)
}
}
if rec != nil && rec.ProposedFix != nil {
action.FixID = rec.ProposedFix.ID
action.Description = rec.ProposedFix.Description
action.RiskLevel = rec.ProposedFix.RiskLevel
if strings.TrimSpace(action.RiskLevel) == "" {
action.RiskLevel = rec.ProposedFix.RiskLevel
}
action.Destructive = rec.ProposedFix.Destructive
action.TargetHost = rec.ProposedFix.TargetHost
}
@@ -1681,6 +1702,45 @@ func buildUnifiedFindingHandoffActions(f *unified.UnifiedFinding) []chat.Handoff
return []chat.HandoffAction{action}
}
func livePatrolApprovalForFinding(findingID, orgID string) *approval.ApprovalRequest {
findingID = strings.TrimSpace(findingID)
if findingID == "" {
return nil
}
store := approval.GetStore()
if store == nil {
return nil
}
var selected *approval.ApprovalRequest
for _, req := range store.GetPendingApprovalsForOrg(orgID) {
if req == nil {
continue
}
if !strings.EqualFold(strings.TrimSpace(req.ToolID), "investigation_fix") {
continue
}
if strings.TrimSpace(req.TargetID) != findingID {
continue
}
if selected == nil ||
req.RequestedAt.After(selected.RequestedAt) ||
(req.RequestedAt.Equal(selected.RequestedAt) && strings.TrimSpace(req.ID) > strings.TrimSpace(selected.ID)) {
selected = req
}
}
return selected
}
func firstNonEmptyString(values ...string) string {
for _, value := range values {
if normalized := strings.TrimSpace(value); normalized != "" {
return normalized
}
}
return ""
}
func formatChatResource(name, resourceType string) string {
name = strings.TrimSpace(name)
resourceType = strings.TrimSpace(resourceType)
@@ -1949,13 +2009,14 @@ func (h *AIHandler) HandleChat(w http.ResponseWriter, r *http.Request) {
}
if findingID != "" {
findingResolved := false
store := h.GetUnifiedStoreForOrg(GetOrgID(ctx))
orgID := GetOrgID(ctx)
store := h.GetUnifiedStoreForOrg(orgID)
if store != nil {
if f := store.Get(findingID); f != nil {
findingResolved = true
handoffContext = buildUnifiedFindingChatContext(f, store)
handoffResources = buildUnifiedFindingHandoffResources(f, store)
handoffActions = buildUnifiedFindingHandoffActions(f)
handoffActions = buildUnifiedFindingHandoffActions(f, orgID)
}
}
if !findingResolved {
+108
View File
@@ -818,6 +818,114 @@ func TestHandleChat_IncludesInvestigationRecordContext(t *testing.T) {
assert.Equal(t, http.StatusOK, w.Code)
}
func TestHandleChat_RecoversLivePatrolApprovalForFindingHandoffAction(t *testing.T) {
cfg := &config.Config{}
h := newTestAIHandler(cfg, nil, nil)
mockSvc := new(MockAIService)
h.defaultService = mockSvc
prevStore := approval.GetStore()
approvalStore, err := approval.NewStore(approval.StoreConfig{
DataDir: t.TempDir(),
DefaultTimeout: 10 * time.Minute,
MaxApprovals: 10,
})
assert.NoError(t, err)
approval.SetStore(approvalStore)
t.Cleanup(func() {
approval.SetStore(prevStore)
})
assert.NoError(t, approvalStore.CreateApproval(&approval.ApprovalRequest{
ID: "approval-live",
ToolID: "investigation_fix",
Command: "systemctl restart workload.service",
TargetType: "vm",
TargetID: "finding-123",
TargetName: "web-server",
Context: "Restart the workload service after backup saturation clears.",
RiskLevel: approval.RiskHigh,
}))
detectedAt := time.Date(2026, 5, 6, 12, 0, 0, 0, time.UTC)
store := unified.NewUnifiedStore(unified.DefaultAlertToFindingConfig())
store.AddFromAI(&unified.UnifiedFinding{
ID: "finding-123",
Source: unified.SourceAIPatrol,
Severity: unified.SeverityCritical,
Category: unified.CategoryPerformance,
ResourceID: "vm-100",
ResourceName: "web-server",
ResourceType: "vm",
Node: "pve-1",
Title: "High CPU usage",
Description: "CPU stayed above 95%.",
InvestigationStatus: "completed",
InvestigationOutcome: "fix_queued",
LoopState: "awaiting_approval",
InvestigationRecord: &aicontracts.InvestigationRecord{
ID: "investigation-123",
FindingID: "finding-123",
Subject: aicontracts.InvestigationRecordSubject{
ResourceID: "vm-100",
ResourceName: "web-server",
ResourceType: "vm",
Node: "pve-1",
},
Trigger: aicontracts.InvestigationRecordTrigger{
Title: "High CPU usage",
DetectedAt: detectedAt,
},
Status: aicontracts.InvestigationStatusCompleted,
Outcome: aicontracts.OutcomeFixQueued,
Confidence: aicontracts.InvestigationRecordConfidenceHigh,
Conclusion: "Backup job saturated CPU.",
ProposedFix: &aicontracts.InvestigationRecordFix{
ID: "fix-123",
Description: "Restart the workload service",
Commands: []string{"systemctl restart workload.service"},
RiskLevel: "medium",
TargetHost: "pve-1",
Destructive: true,
},
StartedAt: detectedAt,
},
})
h.SetUnifiedStore(store)
mockSvc.On("IsRunning").Return(true)
mockSvc.
On("ExecuteStream", mock.Anything, mock.Anything, mock.Anything).
Return(nil).
Run(func(args mock.Arguments) {
reqArg := args.Get(1).(chat.ExecuteRequest)
assert.Equal(t, []chat.HandoffAction{{
FindingID: "finding-123",
RecordID: "investigation-123",
ApprovalID: "approval-live",
FixID: "fix-123",
Description: "Restart the workload service",
RiskLevel: "high",
Destructive: true,
TargetHost: "pve-1",
TargetResourceID: "vm-100",
TargetResourceName: "web-server",
TargetResourceType: "vm",
TargetNode: "pve-1",
}}, reqArg.HandoffActions)
assert.NotContains(t, reqArg.HandoffContext, "systemctl restart workload.service")
assert.NotContains(t, fmt.Sprintf("%#v", reqArg.HandoffActions), "systemctl restart workload.service")
})
body := `{"prompt":"What approval is waiting?","finding_id":"finding-123"}`
req := httptest.NewRequest("POST", "/api/ai/chat", strings.NewReader(body))
w := httptest.NewRecorder()
h.HandleChat(w, req)
assert.Equal(t, http.StatusOK, w.Code)
}
func TestHandleChat_RefreshesStoredFindingContextForFollowUp(t *testing.T) {
cfg := &config.Config{}
h := newTestAIHandler(cfg, nil, nil)
+2 -1
View File
@@ -144,7 +144,8 @@ func TestContract_AssistantFindingContextUsesModelOnlyHandoff(t *testing.T) {
`svc.ClearModelHandoffContext(ctx, sessionID)`,
"handoffContext = buildUnifiedFindingChatContext(f, store)",
"handoffResources = buildUnifiedFindingHandoffResources(f, store)",
"handoffActions = buildUnifiedFindingHandoffActions(f)",
"handoffActions = buildUnifiedFindingHandoffActions(f, orgID)",
"livePatrolApprovalForFinding(f.ID, orgID)",
`appendChatContextLine(&b, "Finding Status", unifiedFindingChatStatus(f, time.Now()))`,
`appendChatContextLine(&b, "Finding Detected At", f.DetectedAt.Format(time.RFC3339))`,
`appendChatContextLine(&b, "Finding Last Seen At", f.LastSeenAt.Format(time.RFC3339))`,