diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index 383c471b7..16823544b 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -309,7 +309,13 @@ runtime cost control, and shared AI transport surfaces. while preserving the safe visible briefing and request-local approval-required posture; later turns must rely on backend-owned session model-context hydration and current canonical stores instead of resending - stale browser handoff payloads. + stale browser handoff payloads. Patrol approval-row Assistant entries are + still Patrol finding handoffs, not local prompt-only shortcuts: live + approval rows, expired proposed-fix rows, and missing-detail queued-fix + recovery rows must route through the shared Patrol finding handoff builder + so the backend receives the same bounded model-only finding context, + resource reference, and safe action reference posture that the main finding + handoff uses. 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. diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index eaaa8ce9c..75818e5ae 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -877,18 +877,20 @@ frontend primitive boundary. 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 - follow that same drawer primitive contract: safe approval metadata may enter - the prompt and context, but raw command text stays out and the scoped - request must pass `autonomousMode:false` instead of changing the user's - persistent Assistant control level. Patrol remediation-plan drawer handoffs - must use the same primitive boundary: plan title/status/risk, step labels, - and command counts may enter Assistant context; raw command and rollback - command payloads must stay in the governed remediation/action panel. Finding - discussion handoffs that reference a live approval, proposed fix, fix - outcome, or remediation plan must also pass `autonomousMode:false` as a - request-local override so the drawer shows approval-required posture without - mutating the persistent Assistant control setting. + briefs. Patrol approval-row Assistant prompts must route through the same + feature-owned finding handoff helper rather than hand-written prompt-only + drawer opens: safe approval metadata, proposed-fix summaries, resource + references, and bounded `handoff_actions` may enter the prompt and context, + but raw command text stays out and the scoped request must pass + `autonomousMode:false` instead of changing the user's persistent Assistant + control level. Patrol remediation-plan drawer handoffs must use the same + primitive boundary: plan title/status/risk, step labels, and command counts + may enter Assistant context; raw command and rollback command payloads must + stay in the governed remediation/action panel. Finding discussion handoffs + that reference a live approval, proposed fix, fix outcome, or remediation + plan must also pass `autonomousMode:false` as a request-local override so + the drawer shows approval-required posture without mutating the persistent + Assistant control setting. 11. Keep shared filter primitives coherent with route-owned option hydration. Feature shells such as `frontend-modern/src/features/infrastructure/` must keep a route-owned canonical option visible in shared selects like diff --git a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md index b6da291fe..d6e954613 100644 --- a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md +++ b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md @@ -171,13 +171,16 @@ Patrol-specific presentation helpers. sync. The model-only context may include current finding status, recurrence, investigation record facts, evidence, verification, approval posture, dry-run posture, proposed-fix summary, and target resource references without - raw command payloads. Inline - Patrol approval actions that open Assistant must follow the - 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 + raw command payloads. Inline Patrol approval actions in + `frontend-modern/src/components/patrol/ApprovalSection.tsx` that open + Assistant must follow that same Patrol-owned handoff model rather than a + prompt-only local shortcut: pass approval ID/status/risk/target plus safe + summary/count metadata as review context, attach the target resource + reference, include bounded `handoff_actions` for live approvals or structured + proposed fixes when present, force the request-local approval-required mode, + attach the Patrol-owned visible drawer briefing for the pending approval or + queued-fix recovery state, 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 diff --git a/frontend-modern/src/components/patrol/ApprovalSection.tsx b/frontend-modern/src/components/patrol/ApprovalSection.tsx index e77107511..0f9d1f802 100644 --- a/frontend-modern/src/components/patrol/ApprovalSection.tsx +++ b/frontend-modern/src/components/patrol/ApprovalSection.tsx @@ -13,7 +13,7 @@ import { hasFeature } from '@/stores/license'; import { AIAPI, type ApprovalRequest, type ApprovalExecutionResult } from '@/api/ai'; import { getApprovalRiskPresentation } from '@/utils/approvalRiskPresentation'; import { - buildPatrolAssistantFindingBriefing, + buildPatrolAssistantFindingHandoff, buildPatrolAssistantApprovalBriefingInput, buildPatrolAssistantProposedFixBriefingInput, type PatrolAssistantProposedFixBriefingSource, @@ -44,28 +44,44 @@ export const ApprovalSection: Component = (props) => { const canAutoFix = createMemo(() => hasFeature('ai_autofix')); - const approvalBriefing = ( + const proposedFixBriefing = ( approval: ApprovalRequest | null, fix?: PatrolAssistantProposedFixBriefingSource | null, ) => - buildPatrolAssistantFindingBriefing({ + buildPatrolAssistantProposedFixBriefingInput( + fix || + (approval + ? { + description: approval.context, + riskLevel: approval.riskLevel, + targetHost: approval.targetName, + commandCount: approval.command ? 1 : 0, + } + : null), + ); + + const assistantHandoff = ( + approval: ApprovalRequest | null, + fix?: PatrolAssistantProposedFixBriefingSource | null, + ) => + buildPatrolAssistantFindingHandoff({ + id: props.findingId, title: props.findingTitle || 'Patrol finding', subject: props.resourceName || 'affected resource', + description: + approval?.context || + fix?.description || + (!approval && props.investigationOutcome === 'fix_queued' + ? 'The original approval details are no longer available. Recover or regenerate the governed approval before execution.' + : undefined), findingStatus: 'active', investigationOutcome: props.investigationOutcome, loopState: props.investigationOutcome || 'awaiting_approval', + resourceId: props.resourceId, + resourceName: props.resourceName, + resourceType: props.resourceType, pendingApproval: buildPatrolAssistantApprovalBriefingInput(approval), - proposedFix: buildPatrolAssistantProposedFixBriefingInput( - fix || - (approval - ? { - description: approval.context, - riskLevel: approval.riskLevel, - targetHost: approval.targetName, - commandCount: approval.command ? 1 : 0, - } - : null), - ), + proposedFix: proposedFixBriefing(approval, fix), }); const handleFixWithAssistant = ( @@ -74,47 +90,14 @@ export const ApprovalSection: Component = (props) => { e: Event, ) => { e.stopPropagation(); - const desc = approval?.context || fix?.description || 'No description available'; - const targetHost = approval?.targetName || fix?.target_host; - const riskLevel = approval?.riskLevel || fix?.risk_level || 'unknown'; - const rationale = fix?.rationale; - - let prompt = `Patrol investigated a finding and queued a governed fix for review.\n\n**Finding:** ${props.findingTitle || 'Unknown finding'} on ${props.resourceName || 'unknown resource'}\n**Proposed fix:** ${desc}`; - if (approval?.id) prompt += `\n**Approval:** ${approval.id}`; - if (approval?.status) prompt += `\n**Approval status:** ${approval.status}`; - if (targetHost) prompt += `\n**Target:** ${targetHost}`; - prompt += `\n**Risk level:** ${riskLevel}`; - if (rationale) prompt += `\n**Rationale:** ${rationale}`; - prompt += - '\n\nUse the attached finding context and governed approval flow. Do not infer, repeat, or execute raw command text from this chat handoff.'; - - aiChatStore.openWithPrompt(prompt, { - targetType: props.resourceType, - targetId: props.resourceId, - findingId: props.findingId, - briefing: approvalBriefing(approval, fix), - autonomousMode: false, - }); + const handoff = assistantHandoff(approval, fix); + aiChatStore.openWithPrompt(handoff.prompt, handoff.context); }; const handleDiscussQueuedFix = (e: Event) => { e.stopPropagation(); - const title = props.findingTitle || 'Patrol finding'; - const subject = props.resourceName || 'affected resource'; - aiChatStore.openWithPrompt( - [ - `Patrol queued a governed fix for ${title} on ${subject}, but the live approval payload is no longer available.`, - 'Use the attached Patrol briefing to explain the current action state, approval recovery path, and safest next step.', - 'Do not infer, repeat, or execute raw command text from this chat handoff.', - ].join('\n\n'), - { - targetType: props.resourceType, - targetId: props.resourceId, - findingId: props.findingId, - briefing: approvalBriefing(null), - autonomousMode: false, - }, - ); + const handoff = assistantHandoff(null); + aiChatStore.openWithPrompt(handoff.prompt, handoff.context); }; // Load investigation details when outcome indicates a fix was proposed/executed diff --git a/frontend-modern/src/components/patrol/__tests__/ApprovalSection.test.tsx b/frontend-modern/src/components/patrol/__tests__/ApprovalSection.test.tsx index 4d505fdbf..142b6beaa 100644 --- a/frontend-modern/src/components/patrol/__tests__/ApprovalSection.test.tsx +++ b/frontend-modern/src/components/patrol/__tests__/ApprovalSection.test.tsx @@ -122,9 +122,15 @@ describe('ApprovalSection', () => { fireEvent.click(screen.getByRole('button', { name: /discuss with assistant/i })); - expect(openWithPromptMock).toHaveBeenCalledWith( - expect.stringContaining('Patrol queued a governed fix for CPU saturation on node-1'), - { + expect(openWithPromptMock).toHaveBeenCalledTimes(1); + const [prompt, context] = openWithPromptMock.mock.calls[0]; + expect(prompt).toContain( + "I'd like to discuss this Patrol finding: \"CPU saturation\" on node-1", + ); + expect(prompt).toContain('Start by reviewing the governed proposed fix or action posture'); + expect(prompt).toContain('Recover or regenerate the governed approval before execution'); + expect(context).toEqual( + expect.objectContaining({ targetType: 'host', targetId: 'host-1', findingId: 'finding-1', @@ -144,8 +150,24 @@ describe('ApprovalSection', () => { ], }), autonomousMode: false, - }, + handoffResources: [{ id: 'host-1', name: 'node-1', node: undefined, type: 'host' }], + handoffActions: undefined, + context: expect.objectContaining({ + source: 'pulse-patrol-finding', + findingId: 'finding-1', + resourceId: 'host-1', + resourceName: 'node-1', + resourceType: 'host', + actionReferenceCount: 0, + }), + }), ); + expect(context.handoffContext).toContain('[Patrol Finding Context]'); + expect(context.handoffContext).toContain('Finding ID: finding-1'); + expect(context.handoffContext).toContain( + 'Recover or regenerate the governed approval before execution', + ); + expect(context.handoffContext).toContain('Command Boundary:'); }); it('opens Assistant from a pending Patrol approval without carrying raw command text', async () => { @@ -180,39 +202,70 @@ describe('ApprovalSection', () => { expect(openWithPromptMock).toHaveBeenCalledTimes(1); const [prompt, context] = openWithPromptMock.mock.calls[0]; - expect(prompt).toContain('queued a governed fix for review'); - expect(prompt).toContain('**Approval:** approval-1'); - expect(prompt).toContain('**Approval status:** pending'); - expect(prompt).toContain('**Risk level:** high'); + expect(prompt).toContain('Start by reviewing governed approval approval-1'); + expect(prompt).toContain('approval status pending'); + expect(prompt).toContain('high risk'); expect(prompt).not.toContain('systemctl restart nginx'); expect(prompt).not.toContain('Please execute this fix'); - expect(context).toEqual({ - targetType: 'agent', - targetId: 'agent-1', - findingId: 'finding-1', - briefing: expect.objectContaining({ - sourceLabel: 'Pulse Patrol', - title: 'Operator briefing attached', - subject: 'CPU saturation on node-1', - 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: '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', + expect(context).toEqual( + expect.objectContaining({ + targetType: 'agent', + targetId: 'agent-1', + findingId: 'finding-1', + briefing: expect.objectContaining({ + sourceLabel: 'Pulse Patrol', + title: 'Operator briefing attached', + subject: 'CPU saturation on node-1', + 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: '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, + handoffResources: [{ id: 'agent-1', name: 'node-1', node: undefined, type: 'agent' }], + handoffActions: [ + expect.objectContaining({ + findingId: 'finding-1', + approvalId: 'approval-1', + approvalStatus: 'pending', + actionRequiresApproval: true, + description: 'Restart the workload service', + riskLevel: 'high', + destructive: false, + targetHost: 'node-1', + targetResourceId: 'agent-1', + targetResourceName: 'node-1', + targetResourceType: 'agent', + }), ], + context: expect.objectContaining({ + source: 'pulse-patrol-finding', + findingId: 'finding-1', + resourceId: 'agent-1', + resourceName: 'node-1', + resourceType: 'agent', + pendingApprovalId: 'approval-1', + actionReferenceCount: 1, + }), }), - autonomousMode: false, - }); + ); + expect(context.handoffContext).toContain('[Patrol Finding Context]'); + expect(context.handoffContext).toContain('Approval: approval-1'); + expect(context.handoffContext).toContain('Approval Status: pending'); + expect(context.handoffContext).toContain('Command Boundary:'); + expect(JSON.stringify(context.handoffActions)).not.toContain('systemctl restart nginx'); expect(JSON.stringify(context.briefing)).not.toContain('systemctl restart nginx'); }); @@ -252,37 +305,69 @@ describe('ApprovalSection', () => { expect(openWithPromptMock).toHaveBeenCalledTimes(1); const [prompt, context] = openWithPromptMock.mock.calls[0]; - expect(prompt).toContain('queued a governed fix for review'); - expect(prompt).toContain('**Proposed fix:** Restart the workload service'); - expect(prompt).toContain('**Target:** node-1'); - expect(prompt).toContain('**Risk level:** high'); + expect(prompt).toContain('Start by reviewing the governed proposed fix or action posture'); + expect(prompt).toContain('proposed fix Restart the workload service'); + expect(prompt).toContain('target node-1'); + expect(prompt).toContain('high risk'); expect(prompt).not.toContain('systemctl restart nginx'); - expect(context).toEqual({ - targetType: 'agent', - targetId: 'agent-1', - findingId: 'finding-1', - briefing: expect.objectContaining({ - sourceLabel: 'Pulse Patrol', - title: 'Operator briefing attached', - subject: 'CPU saturation on node-1', - statusLabel: 'Fix Queued', - detailLines: expect.arrayContaining([ - expect.stringContaining('fix queued for governed review'), - expect.stringContaining('Proposed fix: Restart the workload service'), - expect.stringContaining('Recover or regenerate the governed approval before execution'), - ]), - actionLabel: 'Restart the workload service', - commandSummary: '1 command recorded for approval context', - safetyNote: - 'Command details stay in approval context; destructive actions require governed approval.', - suggestedPrompts: [ - 'Review approval risk and next step', - 'Explain current finding status', - 'Summarize remediation without command text', + expect(context).toEqual( + expect.objectContaining({ + targetType: 'agent', + targetId: 'agent-1', + findingId: 'finding-1', + briefing: expect.objectContaining({ + sourceLabel: 'Pulse Patrol', + title: 'Operator briefing attached', + subject: 'CPU saturation on node-1', + statusLabel: 'Fix Queued', + detailLines: expect.arrayContaining([ + expect.stringContaining('fix queued for governed review'), + expect.stringContaining('Proposed fix: Restart the workload service'), + expect.stringContaining( + 'Recover or regenerate the governed approval before execution', + ), + ]), + actionLabel: 'Restart the workload service', + commandSummary: '1 command recorded for approval context', + safetyNote: + 'Command details stay in approval context; destructive actions require governed approval.', + suggestedPrompts: [ + 'Review approval risk and next step', + 'Explain current finding status', + 'Summarize remediation without command text', + ], + }), + autonomousMode: false, + handoffResources: [{ id: 'agent-1', name: 'node-1', node: undefined, type: 'agent' }], + handoffActions: [ + expect.objectContaining({ + findingId: 'finding-1', + approvalId: undefined, + actionRequiresApproval: false, + description: 'Restart the workload service', + riskLevel: 'high', + destructive: true, + targetHost: 'node-1', + targetResourceId: 'agent-1', + targetResourceName: 'node-1', + targetResourceType: 'agent', + }), ], + context: expect.objectContaining({ + source: 'pulse-patrol-finding', + findingId: 'finding-1', + resourceId: 'agent-1', + resourceName: 'node-1', + resourceType: 'agent', + pendingApprovalId: undefined, + actionReferenceCount: 1, + }), }), - autonomousMode: false, - }); + ); + expect(context.handoffContext).toContain('[Patrol Finding Context]'); + expect(context.handoffContext).toContain('Proposed Fix:'); + expect(context.handoffContext).toContain('Command Boundary:'); + expect(JSON.stringify(context.handoffActions)).not.toContain('systemctl restart nginx'); expect(JSON.stringify(context.briefing)).not.toContain('systemctl restart nginx'); });