diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index d38bb66a0..5f2a2c9a0 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -320,8 +320,9 @@ the canonical monitored-system blocked payload. 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. Patrol + record, but the payload may carry only IDs, status/risk/target metadata, + safe generated approval summaries, command counts, and fix/action + references, never the approval command payload. Patrol remediation-plan handoffs must use the same boundary for frontend-authored prompts: plan status, risk, step labels, and command counts are allowed, while raw command and rollback command payloads remain in governed action @@ -341,7 +342,11 @@ the canonical monitored-system blocked payload. recovery handoffs may use a still-available structured proposed-fix payload only as safe metadata: description, target, risk, rationale, destructive posture, and command count may enter the briefing, while raw command text - remains owned by governed remediation or approval surfaces. Direct + remains owned by governed remediation or approval surfaces. If the unified + finding list lacks a full investigation record, frontend finding-discussion + handoffs may hydrate the latest investigation session for the same safe + proposed-fix metadata, but they must not paste raw proposed-fix command text + into the authored prompt or visible briefing. Direct alert-investigation API handoffs through `internal/api/ai_handlers.go` must enforce that same request-scoped boundary by setting `ai.ExecuteRequest.AutonomousMode` to @@ -453,8 +458,8 @@ the canonical monitored-system blocked payload. and that same Patrol investigation-context owner, so visible Assistant drawer handoffs may include live pending-approval metadata only as safe operator context: approval ID, status, risk, requested/expiry timestamps, - and target label are allowed, while approval command payloads stay inside - governed approval/remediation surfaces + target label, generated approval summary, and command count are allowed, while + approval command payloads stay inside governed approval/remediation surfaces and the dedicated `frontend-modern/src/stores/aiIntelligenceSummaryModel.ts` owner, so recent-change counts and governed policy-posture fallbacks normalize once at the shared store boundary instead of as Patrol-hook-local payload repair and the shared `frontend-modern/src/components/Infrastructure/ResourceCorrelationSummary.tsx` card, so learned correlations and correlation context stay rendered through one governed frontend card instead of separate page-local list loops and the same shared correlation card's ordering and truncation rule, so callers pass raw correlations instead of encoding their own top-N sort behavior diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 0a8cb9ebd..b821e9bc7 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -840,9 +840,13 @@ frontend primitive boundary. yet, rather than opening the shared drawer as empty generic chat. When the feature helper adds live approval state to the generic drawer briefing, it may pass only safe approval metadata into - `AIChatContextBriefing`; raw approval commands remain owned by the governed - approval/remediation panels. The shared approval-required drawer banner must - derive its subject from that briefing or structured finding context, so + `AIChatContextBriefing`, including generated approval summaries and command + counts when available; raw approval commands remain owned by the governed + approval/remediation panels. If the generic finding-level helper hydrates + latest investigation detail to recover proposed-fix context, it may pass only + safe summary fields and command counts into the drawer briefing. The shared + approval-required drawer banner must derive its subject from that briefing or + structured finding context, so Patrol handoffs render as Patrol handoffs or Patrol findings, and alert handoffs render as alert investigations, rather than generic dashboard briefs. Patrol approval-row Assistant prompts must diff --git a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md index 554edce1f..37a273a60 100644 --- a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md +++ b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md @@ -156,14 +156,16 @@ Patrol-specific presentation helpers. generic empty Assistant drawer. When a live pending Patrol approval exists for that finding, the visible Assistant briefing may include only safe approval metadata such as approval ID, pending status, risk, requested time, - expiry, and target label; it must not copy the approval command payload into - Assistant drawer prose. The model-only runtime briefing must apply that same + expiry, target label, generated approval summary, and command count; it must + not copy the approval command payload into Assistant drawer prose. The + model-only runtime briefing must apply that same recovered approval reference when framing the operator decision and action posture. Inline Patrol approval actions that open Assistant must follow the - same rule: pass approval ID/status/risk/target as review context, force the - request-local approval-required mode, attach the Patrol-owned visible drawer - briefing for the pending approval, and never paste the approval command or - proposed-fix command text into the chat prompt. Remediation-plan Assistant + same rule: pass approval ID/status/risk/target plus safe summary/count + metadata as review context, force the request-local approval-required mode, + attach the Patrol-owned visible drawer briefing for the pending approval, and + never paste the approval command or proposed-fix command text into the chat + prompt. Remediation-plan Assistant handoffs follow the same boundary: step labels, plan status, risk, and command counts are allowed, safe suggested prompts may ask about plan risk, prerequisites, rollback, and verification, while command and rollback command @@ -187,6 +189,12 @@ Patrol-specific presentation helpers. and command count. Raw command text remains in the governed remediation or approval panel, while Assistant gets enough context to explain approval recovery and risk without becoming an execution surface. + Generic finding-level Assistant handoffs must use that same safe metadata + boundary when the list response lacks a full investigation record: they may + hydrate the latest investigation session to recover proposed-fix summary, + risk, target, rationale, destructive posture, and command count, but they must + still keep command text out of both the user-authored prompt and visible + Assistant briefing. If the referenced finding is no longer current, Assistant must drop the stored handoff instead of continuing from stale Patrol context. Assistant handoff context must also carry the unified diff --git a/frontend-modern/src/components/AI/FindingsPanel.tsx b/frontend-modern/src/components/AI/FindingsPanel.tsx index a6b82549b..6526e10fb 100644 --- a/frontend-modern/src/components/AI/FindingsPanel.tsx +++ b/frontend-modern/src/components/AI/FindingsPanel.tsx @@ -20,13 +20,16 @@ import { aiChatStore } from '@/stores/aiChat'; import { buildPatrolAssistantFindingBriefing, buildPatrolAssistantFindingPrompt, + buildPatrolAssistantProposedFixBriefingInput, buildPatrolRemediationPlanAssistantBriefing, buildPatrolRemediationPlanAssistantPrompt, patrolAssistantFindingHandoffRequiresApprovalMode, + type PatrolAssistantApprovalBriefingInput, + type PatrolAssistantProposedFixBriefingInput, } from '@/features/patrol/patrolInvestigationContextModel'; import { useResources } from '@/hooks/useResources'; import { InvestigationSection, ApprovalSection } from '@/components/patrol'; -import type { RemediationPlan } from '@/api/ai'; +import { AIAPI, type ApprovalRequest, type RemediationPlan } from '@/api/ai'; import type { PatrolRunRecord, PatrolRuntimeState } from '@/api/patrol'; import { buildResolvedResourceSurfaceLinks } from '@/routing/resourceLinks'; import { getApprovalRiskPresentation } from '@/utils/approvalRiskPresentation'; @@ -420,6 +423,55 @@ export const FindingsPanel: Component = (props) => { setEditingNoteId(null); }; + const buildLiveApprovalProposedFixBriefing = ( + approval: ApprovalRequest | undefined, + ): PatrolAssistantProposedFixBriefingInput | undefined => + buildPatrolAssistantProposedFixBriefingInput( + approval + ? { + description: approval.context, + riskLevel: approval.riskLevel, + targetHost: approval.targetName, + commandCount: approval.command ? 1 : 0, + } + : null, + ); + + const loadLatestInvestigationProposedFixBriefing = async ( + finding: UnifiedFinding, + pendingApprovalBriefing: PatrolAssistantApprovalBriefingInput | undefined, + ): Promise => { + if (finding.investigationRecord?.proposed_fix) { + return undefined; + } + const hasInvestigationPointer = Boolean( + finding.investigationOutcome || + finding.investigationSessionId || + finding.lastInvestigatedAt || + pendingApprovalBriefing?.id, + ); + if (!hasInvestigationPointer) { + return undefined; + } + if ( + !patrolAssistantFindingHandoffRequiresApprovalMode({ + investigationOutcome: finding.investigationOutcome, + remediationId: finding.remediationPlanId, + pendingApproval: pendingApprovalBriefing, + investigationRecord: finding.investigationRecord, + }) + ) { + return undefined; + } + + try { + const investigation = await AIAPI.getInvestigation(finding.id); + return buildPatrolAssistantProposedFixBriefingInput(investigation?.proposed_fix); + } catch { + return undefined; + } + }; + const handleDiscussWithAssistant = async (finding: UnifiedFinding, e: Event) => { e.stopPropagation(); await aiIntelligenceStore.loadPendingApprovals(); @@ -438,6 +490,12 @@ export const FindingsPanel: Component = (props) => { targetName: pendingApproval.targetName, } : undefined; + const latestInvestigationProposedFix = await loadLatestInvestigationProposedFixBriefing( + finding, + pendingApprovalBriefing, + ); + const proposedFix = + latestInvestigationProposedFix || buildLiveApprovalProposedFixBriefing(pendingApproval); const prompt = buildPatrolAssistantFindingPrompt({ title, subject, @@ -456,6 +514,7 @@ export const FindingsPanel: Component = (props) => { lastRegressionAt: finding.lastRegressionAt, remediationId: finding.remediationPlanId, pendingApproval: pendingApprovalBriefing, + proposedFix, investigationRecord: finding.investigationRecord, }); const requiresApprovalMode = patrolAssistantFindingHandoffRequiresApprovalMode({ diff --git a/frontend-modern/src/components/AI/__tests__/FindingsPanel.test.ts b/frontend-modern/src/components/AI/__tests__/FindingsPanel.test.ts index 3c2c4840b..c1425baaa 100644 --- a/frontend-modern/src/components/AI/__tests__/FindingsPanel.test.ts +++ b/frontend-modern/src/components/AI/__tests__/FindingsPanel.test.ts @@ -45,9 +45,12 @@ const patrolWorkspaceSource = readFileSync( describe('FindingsPanel assistant handoff', () => { it('routes Patrol investigation records into the Assistant briefing context', () => { expect(findingsPanelSource).toContain('buildPatrolAssistantFindingBriefing'); + expect(findingsPanelSource).toContain('buildPatrolAssistantProposedFixBriefingInput'); expect(findingsPanelSource).toContain('briefing,'); expect(findingsPanelSource).toContain('investigationRecord: finding.investigationRecord'); expect(findingsPanelSource).toContain('pendingApproval: pendingApproval'); + expect(findingsPanelSource).toContain('proposedFix,'); + expect(findingsPanelSource).toContain('AIAPI.getInvestigation(finding.id)'); expect(findingsPanelSource).toContain('await aiIntelligenceStore.loadPendingApprovals()'); expect(findingsPanelSource).toContain('patrolAssistantFindingHandoffRequiresApprovalMode'); expect(findingsPanelSource).toContain( diff --git a/frontend-modern/src/components/patrol/ApprovalSection.tsx b/frontend-modern/src/components/patrol/ApprovalSection.tsx index dac87a58e..d6c49630e 100644 --- a/frontend-modern/src/components/patrol/ApprovalSection.tsx +++ b/frontend-modern/src/components/patrol/ApprovalSection.tsx @@ -12,7 +12,11 @@ import { aiChatStore } from '@/stores/aiChat'; import { hasFeature } from '@/stores/license'; import { AIAPI, type ApprovalRequest, type ApprovalExecutionResult } from '@/api/ai'; import { getApprovalRiskPresentation } from '@/utils/approvalRiskPresentation'; -import { buildPatrolAssistantFindingBriefing } from '@/features/patrol/patrolInvestigationContextModel'; +import { + buildPatrolAssistantFindingBriefing, + buildPatrolAssistantProposedFixBriefingInput, + type PatrolAssistantProposedFixBriefingSource, +} from '@/features/patrol/patrolInvestigationContextModel'; import { RemediationStatus } from './RemediationStatus'; interface ApprovalSectionProps { @@ -24,15 +28,6 @@ interface ApprovalSectionProps { resourceId?: string; } -interface AssistantBriefingFixSource { - description?: string | null; - commands?: string[] | null; - target_host?: string | null; - risk_level?: string | null; - rationale?: string | null; - destructive?: boolean | null; -} - export const ApprovalSection: Component = (props) => { const [actionLoading, setActionLoading] = createSignal(null); const [executionResult, setExecutionResult] = createSignal(null); @@ -50,7 +45,7 @@ export const ApprovalSection: Component = (props) => { const approvalBriefing = ( approval: ApprovalRequest | null, - fix?: AssistantBriefingFixSource | null, + fix?: PatrolAssistantProposedFixBriefingSource | null, ) => buildPatrolAssistantFindingBriefing({ title: props.findingTitle || 'Patrol finding', @@ -68,21 +63,22 @@ export const ApprovalSection: Component = (props) => { targetName: approval.targetName, } : null, - proposedFix: fix - ? { - description: fix.description, - riskLevel: fix.risk_level, - targetHost: fix.target_host, - rationale: fix.rationale, - commandCount: fix.commands?.length ?? 0, - destructive: fix.destructive, - } - : null, + proposedFix: buildPatrolAssistantProposedFixBriefingInput( + fix || + (approval + ? { + description: approval.context, + riskLevel: approval.riskLevel, + targetHost: approval.targetName, + commandCount: approval.command ? 1 : 0, + } + : null), + ), }); const handleFixWithAssistant = ( approval: ApprovalRequest | null, - fix: AssistantBriefingFixSource | null, + fix: PatrolAssistantProposedFixBriefingSource | null, e: Event, ) => { e.stopPropagation(); diff --git a/frontend-modern/src/components/patrol/__tests__/ApprovalSection.test.tsx b/frontend-modern/src/components/patrol/__tests__/ApprovalSection.test.tsx index 5a1bd0b2b..4d505fdbf 100644 --- a/frontend-modern/src/components/patrol/__tests__/ApprovalSection.test.tsx +++ b/frontend-modern/src/components/patrol/__tests__/ApprovalSection.test.tsx @@ -197,10 +197,19 @@ describe('ApprovalSection', () => { statusLabel: 'Pending approval ยท High risk', detailLines: expect.arrayContaining([ expect.stringContaining('live approval pending'), + expect.stringContaining('Proposed fix: Restart the workload service'), + expect.stringContaining('1 command recorded for approval context'), expect.stringContaining('Review live governed approval approval-1 before execution'), ]), - actionLabel: 'Approval approval-1', - safetyNote: 'Execution requires the governed approval flow.', + actionLabel: 'Restart the workload service', + commandSummary: '1 command recorded for approval context', + safetyNote: + 'Command details stay in approval context; execution requires the governed approval flow.', + suggestedPrompts: [ + 'Review approval risk and next step', + 'Explain current finding status', + 'Summarize remediation without command text', + ], }), autonomousMode: false, }); diff --git a/frontend-modern/src/features/patrol/__tests__/patrolInvestigationContextModel.test.ts b/frontend-modern/src/features/patrol/__tests__/patrolInvestigationContextModel.test.ts index bb5434db7..8119e82c8 100644 --- a/frontend-modern/src/features/patrol/__tests__/patrolInvestigationContextModel.test.ts +++ b/frontend-modern/src/features/patrol/__tests__/patrolInvestigationContextModel.test.ts @@ -5,6 +5,7 @@ import { buildPatrolAssessmentAssistantHandoff, buildPatrolAssistantFindingBriefing, buildPatrolAssistantFindingPrompt, + buildPatrolAssistantProposedFixBriefingInput, buildPatrolInvestigationContextSummary, buildPatrolInvestigationRecordPresentation, buildPatrolRemediationPlanAssistantBriefing, @@ -322,6 +323,27 @@ describe('patrolInvestigationContextModel', () => { expect(JSON.stringify(presentation)).not.toContain('systemctl restart workload.service'); }); + it('normalizes safe proposed-fix briefing metadata without command text', () => { + const briefing = buildPatrolAssistantProposedFixBriefingInput({ + description: 'Restart the workload service', + commands: ['systemctl restart workload.service'], + risk_level: 'high', + target_host: 'node-1', + rationale: 'Service stayed wedged after IO pressure.', + destructive: true, + }); + + expect(briefing).toEqual({ + description: 'Restart the workload service', + riskLevel: 'high', + targetHost: 'node-1', + rationale: 'Service stayed wedged after IO pressure.', + commandCount: 1, + destructive: true, + }); + expect(JSON.stringify(briefing)).not.toContain('systemctl restart workload.service'); + }); + it('frames Assistant handoff around the structured Patrol record when one exists', () => { expect( buildPatrolAssistantFindingPrompt({ diff --git a/frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts b/frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts index 6be8dcae1..c121aac59 100644 --- a/frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts +++ b/frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts @@ -77,6 +77,18 @@ export interface PatrolAssistantProposedFixBriefingInput { destructive?: boolean | null; } +export interface PatrolAssistantProposedFixBriefingSource { + description?: string | null; + riskLevel?: string | null; + risk_level?: string | null; + targetHost?: string | null; + target_host?: string | null; + rationale?: string | null; + commandCount?: number | null; + commands?: readonly string[] | null; + destructive?: boolean | null; +} + export interface PatrolAssistantFindingBriefingInput { title: string; subject: string; @@ -225,16 +237,9 @@ export function buildPatrolInvestigationRecordPresentation( }; } - const proposedFix = record.proposed_fix - ? { - description: normalizeText(record.proposed_fix.description), - riskLabel: formatIdentifierLabel(record.proposed_fix.risk_level), - targetHost: normalizeText(record.proposed_fix.target_host), - rationale: normalizeText(record.proposed_fix.rationale), - commandSummary: formatCommandSummary(record.proposed_fix.commands?.length ?? 0), - destructive: Boolean(record.proposed_fix.destructive), - } - : undefined; + const proposedFix = normalizeProposedFixBriefing( + buildPatrolAssistantProposedFixBriefingInput(record.proposed_fix), + ); return { hasRecord: true, @@ -264,6 +269,39 @@ export function buildPatrolInvestigationRecordPresentation( }; } +export function buildPatrolAssistantProposedFixBriefingInput( + source?: PatrolAssistantProposedFixBriefingSource | null, +): PatrolAssistantProposedFixBriefingInput | undefined { + if (!source) return undefined; + const commandCount = + typeof source.commandCount === 'number' + ? source.commandCount + : Array.isArray(source.commands) + ? source.commands.length + : null; + const briefing = { + description: normalizeText(source.description), + riskLevel: normalizeText(source.riskLevel || source.risk_level), + targetHost: normalizeText(source.targetHost || source.target_host), + rationale: normalizeText(source.rationale), + commandCount: normalizeNonNegativeCount(commandCount), + destructive: typeof source.destructive === 'boolean' ? source.destructive : null, + }; + + if ( + !briefing.description && + !briefing.riskLevel && + !briefing.targetHost && + !briefing.rationale && + !briefing.commandCount && + briefing.destructive !== true + ) { + return undefined; + } + + return briefing; +} + export function buildPatrolAssistantFindingPrompt( input: PatrolAssistantFindingPromptInput, ): string { @@ -1389,6 +1427,9 @@ function buildPatrolAssistantSafetyNote( if (hasCommands && isDestructive) { return 'Command details stay in approval context; destructive actions require governed approval.'; } + if (hasCommands && pendingApproval?.id) { + return 'Command details stay in approval context; execution requires the governed approval flow.'; + } if (hasCommands) { return 'Command details stay in approval context.'; } diff --git a/tests/integration/tests/73-patrol-assistant-operator-briefing.spec.ts b/tests/integration/tests/73-patrol-assistant-operator-briefing.spec.ts index 1a89efeac..a6eb6debb 100644 --- a/tests/integration/tests/73-patrol-assistant-operator-briefing.spec.ts +++ b/tests/integration/tests/73-patrol-assistant-operator-briefing.spec.ts @@ -48,6 +48,7 @@ test.describe("Patrol Assistant operator briefing", () => { const approvalRequestedAt = new Date(Date.now() - 60_000).toISOString(); const approvalExpiresAt = new Date(Date.now() + 10 * 60_000).toISOString(); let includePendingApproval = true; + let includeUnifiedInvestigationRecord = true; let includeInvestigationProposedFix = false; await page.route("**/api/security/status", async (route) => { @@ -262,42 +263,46 @@ test.describe("Patrol Assistant operator briefing", () => { investigation_status: "completed", investigation_outcome: "fix_queued", investigation_attempts: 1, - investigation_record: { - id: "record-1", - finding_id: "finding-operator-briefing", - subject: { - resource_id: "host:web-server", - resource_name: "web-server", - resource_type: "host", - }, - trigger: { - detected_at: "2026-05-06T12:00:00Z", - title: "High CPU usage", - }, - status: "completed", - outcome: "fix_queued", - confidence: "high", - conclusion: "Backup job saturated CPU.", - recommended_action: - "Approve a controlled restart after the backup completes.", - evidence: [ - { - kind: "metrics", - summary: "CPU stayed above 95% for 10 minutes", - }, - ], - proposed_fix: { - id: "fix-1", - description: "Restart the workload service", - commands: ["systemctl restart workload.service"], - risk_level: "medium", - destructive: true, - }, - verification: ["CPU returned below 50%"], - tools_used: [], - started_at: "2026-05-06T12:00:00Z", - approval_id: "approval-1", - }, + ...(includeUnifiedInvestigationRecord + ? { + investigation_record: { + id: "record-1", + finding_id: "finding-operator-briefing", + subject: { + resource_id: "host:web-server", + resource_name: "web-server", + resource_type: "host", + }, + trigger: { + detected_at: "2026-05-06T12:00:00Z", + title: "High CPU usage", + }, + status: "completed", + outcome: "fix_queued", + confidence: "high", + conclusion: "Backup job saturated CPU.", + recommended_action: + "Approve a controlled restart after the backup completes.", + evidence: [ + { + kind: "metrics", + summary: "CPU stayed above 95% for 10 minutes", + }, + ], + proposed_fix: { + id: "fix-1", + description: "Restart the workload service", + commands: ["systemctl restart workload.service"], + risk_level: "medium", + destructive: true, + }, + verification: ["CPU returned below 50%"], + tools_used: [], + started_at: "2026-05-06T12:00:00Z", + approval_id: "approval-1", + }, + } + : {}), }, ], count: 1, @@ -510,6 +515,7 @@ test.describe("Patrol Assistant operator briefing", () => { queuedAssistantContext.getByText("systemctl restart workload.service"), ).toHaveCount(0); + includeUnifiedInvestigationRecord = false; includeInvestigationProposedFix = true; await page.reload({ waitUntil: "domcontentloaded" }); await expect(page.getByRole("button", { name: "Findings" })).toBeVisible(); @@ -517,6 +523,32 @@ test.describe("Patrol Assistant operator briefing", () => { await page.getByText("High CPU usage").click(); const expiredFinding = page.locator("#finding-finding-operator-briefing"); await expect(expiredFinding.getByText("approval expired")).toBeVisible(); + await expiredFinding + .getByRole("button", { name: "Discuss with Assistant" }) + .first() + .click(); + + const hydratedFindingAssistantContext = + page.getByLabel("Assistant context"); + await expect(hydratedFindingAssistantContext).toBeVisible(); + await expect(hydratedFindingAssistantContext).toContainText( + "Operator briefing attached", + ); + await expect(hydratedFindingAssistantContext).toContainText("Fix Queued"); + await expect(hydratedFindingAssistantContext).toContainText( + "Proposed fix: Restart the workload service; target web-server; high risk; 1 command recorded for approval context; destructive proposed fix; rationale Workload service stayed wedged after backup pressure.", + ); + await expect( + hydratedFindingAssistantContext.getByRole("button", { + name: "Summarize remediation without command text", + }), + ).toBeVisible(); + await expect( + hydratedFindingAssistantContext.getByText( + "systemctl restart workload.service", + ), + ).toHaveCount(0); + await expiredFinding .getByRole("button", { name: "Fix with Assistant" }) .last()