Clamp Patrol finding chat handoffs to approval mode

This commit is contained in:
rcourtman
2026-05-07 01:31:45 +01:00
parent ad61b6c19f
commit 2c913fac8e
7 changed files with 42 additions and 2 deletions
@@ -1099,6 +1099,11 @@ execution mode is AI/API-owned. Request-scoped `AutonomousMode:false` and
`RequireCommandApproval:true` on `/api/ai/investigate-alert` are Assistant
action-governance facts, not agent install readiness, command reachability, or
fleet-control capability signals.
Patrol finding chat handoffs follow the same ownership split: when
`/api/ai/chat` resolves a `finding_id` into model-only Patrol briefing,
resource, or action context, the backend-enforced `autonomous_mode:false`
clamp is Assistant action-governance, not agent readiness, fleet command
reachability, or enrollment health.
That same shared `internal/api/` dependency also now assumes SSO test and
metadata-preview routes fail closed on validated outbound URL handling.
Lifecycle-adjacent setup and hosted bootstrap surfaces may depend on those
@@ -264,6 +264,13 @@ runtime cost control, and shared AI transport surfaces.
Proposed-fix command text must stay out of both the persisted chat message
and the model-only handoff context, and command payloads remain
approval-context data, not conversational copy.
`/api/ai/chat` must also clamp Patrol finding handoffs that resolve to
model-only briefing, resource, or action context to approval-required mode
by forcing the request-local autonomous-mode override to false, even when a
caller supplied `autonomous_mode:true`. That clamp belongs to the
backend/API execution boundary, does not mutate the user's persistent AI
control setting, and prevents product-originated Patrol action context from
becoming silent command authority.
The Assistant drawer may also render an attached context briefing for that
handoff, but the briefing is runtime context visibility only: it must not
mutate chat control settings, execute tools, or reveal raw command payloads.
@@ -1831,6 +1831,13 @@ overrides. Dashboard Pulse Brief and other scoped handoffs may include
execution for that exchange, but the transport must treat the field as a
request override only and must not mutate the user's persistent AI control
setting.
Patrol finding handoffs are stricter than ordinary chat requests: when
`finding_id` resolves to model-only Patrol briefing, resource, or action
context, `internal/api/ai_handler.go` must clamp the request-local autonomous
mode to false even if the caller supplied `autonomous_mode:true`. That
server-side clamp is part of the public API contract because the frontend
handoff setting is only advisory unless the backend preserves the
approval-required boundary.
That same backend API boundary now also owns the negative space around
assistant control. Wiring native TrueNAS app actions into
`internal/api/router.go`, `internal/api/ai_handler.go`, or adjacent backend
@@ -398,6 +398,11 @@ bypass the API fail-closed execution gate.
`RequireCommandApproval:true` are AI action-governance constraints, not
storage/recovery restore approval, recovery freshness, or storage diagnostic
payload semantics.
Patrol finding chat handoff execution controls in `internal/api/ai_handler.go`
follow the same boundary: backend-forced `autonomous_mode:false` for
`finding_id` handoffs with model-only Patrol briefing, resource, or action
context is Assistant action-governance, not a storage/recovery approval,
recovery freshness, or restore-command signal.
That same adjacent boundary also keeps the retired Patrol quickstart
contract out of storage/recovery ownership: shared AI handlers no longer
expose active quickstart credit, token, or hosted-model provider state, and
+12 -1
View File
@@ -783,6 +783,17 @@ type ChatRequest struct {
AutonomousMode *bool `json:"autonomous_mode,omitempty"`
}
func chatAutonomousModeForFindingHandoff(requested *bool, findingID, handoffContext string, handoffResources []chat.HandoffResource, handoffActions []chat.HandoffAction) *bool {
if strings.TrimSpace(findingID) == "" {
return requested
}
if strings.TrimSpace(handoffContext) == "" && len(handoffResources) == 0 && len(handoffActions) == 0 {
return requested
}
approvalRequired := false
return &approvalRequired
}
const findingChatContextListLimit = 5
type unifiedFindingLookup interface {
@@ -2152,7 +2163,7 @@ func (h *AIHandler) HandleChat(w http.ResponseWriter, r *http.Request) {
HandoffContext: handoffContext,
HandoffResources: handoffResources,
HandoffActions: handoffActions,
AutonomousMode: req.AutonomousMode,
AutonomousMode: chatAutonomousModeForFindingHandoff(req.AutonomousMode, findingID, handoffContext, handoffResources, handoffActions),
}, func(event chat.StreamEvent) {
if event.Type == "done" {
serviceSentDone = true
+4 -1
View File
@@ -727,6 +727,9 @@ func TestHandleChat_IncludesInvestigationRecordContext(t *testing.T) {
reqArg := args.Get(1).(chat.ExecuteRequest)
assert.Equal(t, "finding-123", reqArg.FindingID)
assert.Equal(t, "What happened?", reqArg.Prompt)
if assert.NotNil(t, reqArg.AutonomousMode) {
assert.False(t, *reqArg.AutonomousMode)
}
assert.Contains(t, reqArg.HandoffContext, "[Operator Briefing]")
assert.Contains(t, reqArg.HandoffContext, "Briefing Source: Pulse Patrol structured finding")
assert.Contains(t, reqArg.HandoffContext, "Finding: High CPU usage (critical, performance, active)")
@@ -809,7 +812,7 @@ func TestHandleChat_IncludesInvestigationRecordContext(t *testing.T) {
assert.NotContains(t, fmt.Sprintf("%#v", reqArg.HandoffActions), "systemctl restart workload.service")
})
body := `{"prompt":"What happened?","finding_id":"finding-123"}`
body := `{"prompt":"What happened?","finding_id":"finding-123","autonomous_mode":true}`
req := httptest.NewRequest("POST", "/api/ai/chat", strings.NewReader(body))
w := httptest.NewRecorder()
+2
View File
@@ -146,6 +146,8 @@ func TestContract_AssistantFindingContextUsesModelOnlyHandoff(t *testing.T) {
"handoffContext = buildUnifiedFindingChatContext(f, store, handoffActions)",
"handoffResources = buildUnifiedFindingHandoffResources(f, store)",
"livePatrolApprovalForFinding(f.ID, orgID)",
"func chatAutonomousModeForFindingHandoff(requested *bool, findingID, handoffContext string, handoffResources []chat.HandoffResource, handoffActions []chat.HandoffAction) *bool",
"AutonomousMode: chatAutonomousModeForFindingHandoff(req.AutonomousMode, findingID, handoffContext, handoffResources, handoffActions)",
`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))`,