diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 9b22e7aed..6ad514e34 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index a1ae311ce..16b29cccd 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -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. diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index e4b659ebf..073e646d0 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 481ff66f4..be0deefa3 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -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 diff --git a/internal/api/ai_handler.go b/internal/api/ai_handler.go index 77a648e9c..898540592 100644 --- a/internal/api/ai_handler.go +++ b/internal/api/ai_handler.go @@ -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 diff --git a/internal/api/ai_handler_test.go b/internal/api/ai_handler_test.go index 21af3a722..218db70f3 100644 --- a/internal/api/ai_handler_test.go +++ b/internal/api/ai_handler_test.go @@ -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() diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index dffa2ff11..a007f0042 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -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))`,