From bebe9ca85bd10b80d8483a7b242865e5a6cb16fb Mon Sep 17 00:00:00 2001 From: rcourtman Date: Thu, 7 May 2026 00:58:25 +0100 Subject: [PATCH] Keep Patrol plan handoffs command-free --- .../v6/internal/subsystems/api-contracts.md | 9 +- .../subsystems/frontend-primitives.md | 5 +- .../subsystems/patrol-intelligence.md | 7 +- .../src/components/AI/FindingsPanel.tsx | 19 +-- .../AI/__tests__/FindingsPanel.test.ts | 8 ++ .../patrolInvestigationContextModel.test.ts | 56 ++++++++ .../patrol/patrolInvestigationContextModel.ts | 120 +++++++++++++++++- 7 files changed, 206 insertions(+), 18 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 8463fd373..b6d51167e 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -320,7 +320,11 @@ the canonical monitored-system blocked payload. 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. The operator + 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 + surfaces. The operator decision and action-posture lines in the briefing must derive from those same structured action references after recovery so the briefing cannot contradict the handoff action payload. Related finding @@ -2977,6 +2981,9 @@ approval command text remains inside the governed approval/remediation surface. Patrol approval-row Assistant prompts must use the same safe metadata boundary and set `autonomousMode:false` for the request-local chat handoff; they must not paste raw approval or proposed-fix command text into the authored chat prompt. +Patrol remediation-plan Assistant prompts must also pass only safe plan metadata, +step labels, risk/status, and command counts; raw plan command and rollback +command payloads remain owned by the governed remediation/action APIs and panels. Patrol run-history serialization and persistence must also preserve full field parity across API responses and restart boundaries, including `pmg_checked`, `rejected_findings`, `triage_flags`, `triage_skipped_llm`, and diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 82fbd8d9b..cfb68da99 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -823,7 +823,10 @@ frontend primitive boundary. 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. + 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. 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 d4452b056..cb7bf7db0 100644 --- a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md +++ b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md @@ -160,8 +160,11 @@ Patrol-specific presentation helpers. 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, and never paste the approval command or - proposed-fix command text into the chat prompt. The assembled handoff must - still pass through the Assistant runtime's + 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, while command and rollback command text stays in the + governed remediation or approval surface. The assembled handoff must still + pass through the Assistant runtime's resource-policy sanitizer before prompt injection, so Patrol-owned prose cannot leak governed resource names, IDs, aliases, nodes, paths, or addresses outside the canonical policy boundary. diff --git a/frontend-modern/src/components/AI/FindingsPanel.tsx b/frontend-modern/src/components/AI/FindingsPanel.tsx index 36f73998d..7c8a99a30 100644 --- a/frontend-modern/src/components/AI/FindingsPanel.tsx +++ b/frontend-modern/src/components/AI/FindingsPanel.tsx @@ -20,6 +20,8 @@ import { aiChatStore } from '@/stores/aiChat'; import { buildPatrolAssistantFindingBriefing, buildPatrolAssistantFindingPrompt, + buildPatrolRemediationPlanAssistantBriefing, + buildPatrolRemediationPlanAssistantPrompt, } from '@/features/patrol/patrolInvestigationContextModel'; import { useResources } from '@/hooks/useResources'; import { InvestigationSection, ApprovalSection } from '@/components/patrol'; @@ -465,24 +467,15 @@ export const FindingsPanel: Component = (props) => { e.stopPropagation(); const subject = getFindingSubjectPresentation(finding).label; const title = getFindingTitlePresentation(finding).label; - - let prompt = `Pulse Patrol generated a remediation plan for a finding. Please help me apply it safely.\n\n`; - prompt += `**Finding:** ${title} on ${subject}\n`; - if (plan.title) prompt += `**Plan:** ${plan.title}\n`; - if (plan.risk_level) prompt += `**Risk level:** ${plan.risk_level}\n`; - if (plan.description) prompt += `\n**Plan context:** ${plan.description}\n`; - prompt += `\n**Steps:**\n`; - for (const step of plan.steps || []) { - prompt += `${step.order}. ${step.action}\n`; - if (step.command) prompt += ` Command: \`${step.command}\`\n`; - if (step.rollback_command) prompt += ` Rollback: \`${step.rollback_command}\`\n`; - } - prompt += `\nIf any step is risky or ambiguous, ask me before proceeding.`; + const prompt = buildPatrolRemediationPlanAssistantPrompt({ title, subject, plan }); + const briefing = buildPatrolRemediationPlanAssistantBriefing({ title, subject, plan }); aiChatStore.openWithPrompt(prompt, { targetType: finding.resourceType, targetId: finding.resourceId, findingId: finding.id, + briefing, + autonomousMode: false, }); }; diff --git a/frontend-modern/src/components/AI/__tests__/FindingsPanel.test.ts b/frontend-modern/src/components/AI/__tests__/FindingsPanel.test.ts index 78dfd0c8c..1dd42a091 100644 --- a/frontend-modern/src/components/AI/__tests__/FindingsPanel.test.ts +++ b/frontend-modern/src/components/AI/__tests__/FindingsPanel.test.ts @@ -50,6 +50,14 @@ describe('FindingsPanel assistant handoff', () => { expect(findingsPanelSource).toContain('pendingApproval: pendingApproval'); expect(findingsPanelSource).toContain('await aiIntelligenceStore.loadPendingApprovals()'); }); + + it('routes remediation plan handoffs through the command-free Patrol handoff model', () => { + expect(findingsPanelSource).toContain('buildPatrolRemediationPlanAssistantPrompt'); + expect(findingsPanelSource).toContain('buildPatrolRemediationPlanAssistantBriefing'); + expect(findingsPanelSource).toContain('autonomousMode: false'); + expect(findingsPanelSource).not.toContain('Command: `'); + expect(findingsPanelSource).not.toContain('Rollback: `'); + }); }); describe('aiFindingPresentation', () => { diff --git a/frontend-modern/src/features/patrol/__tests__/patrolInvestigationContextModel.test.ts b/frontend-modern/src/features/patrol/__tests__/patrolInvestigationContextModel.test.ts index 648d6254f..adbd31ea9 100644 --- a/frontend-modern/src/features/patrol/__tests__/patrolInvestigationContextModel.test.ts +++ b/frontend-modern/src/features/patrol/__tests__/patrolInvestigationContextModel.test.ts @@ -1,10 +1,13 @@ import { describe, expect, it } from 'vitest'; +import type { RemediationPlan } from '@/api/ai'; import { buildPatrolAssistantFindingBriefing, buildPatrolAssistantFindingPrompt, buildPatrolInvestigationContextSummary, buildPatrolInvestigationRecordPresentation, + buildPatrolRemediationPlanAssistantBriefing, + buildPatrolRemediationPlanAssistantPrompt, } from '../patrolInvestigationContextModel'; describe('patrolInvestigationContextModel', () => { @@ -224,6 +227,59 @@ describe('patrolInvestigationContextModel', () => { expect(JSON.stringify(briefing)).not.toContain('systemctl restart workload.service'); }); + it('builds remediation plan Assistant handoff context without exposing raw commands', () => { + const plan: RemediationPlan = { + id: 'plan-1', + finding_id: 'finding-1', + resource_id: 'agent-1', + title: 'Restore web service', + description: 'Restart the service and verify health.', + risk_level: 'high', + status: 'pending', + created_at: '2026-05-06T12:00:00Z', + steps: [ + { + order: 1, + action: 'Restart web service', + command: 'systemctl restart nginx', + rollback_command: 'systemctl stop nginx', + risk_level: 'high', + }, + { + order: 2, + action: 'Check service health', + command: 'systemctl status nginx', + risk_level: 'low', + }, + ], + }; + + const prompt = buildPatrolRemediationPlanAssistantPrompt({ + title: 'Nginx down', + subject: 'node-1', + plan, + }); + const briefing = buildPatrolRemediationPlanAssistantBriefing({ + title: 'Nginx down', + subject: 'node-1', + plan, + }); + + expect(prompt).toContain('Pulse Patrol generated a governed remediation plan'); + expect(prompt).toContain('1. Restart web service (high risk; command recorded'); + expect(prompt).toContain('2 commands recorded for governed plan review'); + expect(prompt).not.toContain('systemctl restart nginx'); + expect(prompt).not.toContain('systemctl stop nginx'); + expect(prompt).not.toContain('systemctl status nginx'); + expect(briefing.commandSummary).toBe( + '2 commands recorded for governed plan review; 1 rollback command recorded', + ); + expect(briefing.safetyNote).toBe( + 'Command details stay in governed remediation context; execution requires the approval flow.', + ); + expect(JSON.stringify(briefing)).not.toContain('systemctl'); + }); + it('builds an operator briefing from current finding facts before a Patrol record exists', () => { expect( buildPatrolAssistantFindingBriefing({ diff --git a/frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts b/frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts index cb0a99a5c..768ef0fb6 100644 --- a/frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts +++ b/frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts @@ -2,7 +2,7 @@ import type { CorrelationsResponse, IntelligencePolicyPostureSummary, } from '@/types/aiIntelligence'; -import type { InvestigationRecord } from '@/api/ai'; +import type { InvestigationRecord, RemediationPlan } from '@/api/ai'; import type { AIChatContextBriefing } from '@/stores/aiChat'; export interface PatrolInvestigationContextSummaryInput { @@ -70,6 +70,12 @@ export interface PatrolAssistantFindingBriefingInput { investigationRecord?: InvestigationRecord | null; } +export interface PatrolRemediationPlanAssistantInput { + title: string; + subject: string; + plan: RemediationPlan; +} + export function buildPatrolInvestigationContextSummary( input: PatrolInvestigationContextSummaryInput, ): PatrolInvestigationContextSummary { @@ -170,6 +176,95 @@ export function buildPatrolAssistantFindingPrompt( return prompt; } +export function buildPatrolRemediationPlanAssistantPrompt( + input: PatrolRemediationPlanAssistantInput, +): string { + const title = normalizeText(input.title) || 'Patrol finding'; + const subject = normalizeText(input.subject) || 'the affected resource'; + const plan = input.plan; + const planTitle = normalizeText(plan.title); + const planDescription = normalizeText(plan.description); + const riskLabel = formatIdentifierLabel(plan.risk_level)?.toLowerCase(); + const statusLabel = formatIdentifierLabel(plan.status)?.toLowerCase(); + + let prompt = + 'Pulse Patrol generated a governed remediation plan for a finding. Review it from the attached plan context before suggesting next actions.\n\n'; + prompt += `**Finding:** ${title} on ${subject}\n`; + if (planTitle) prompt += `**Plan:** ${planTitle}\n`; + if (statusLabel) prompt += `**Plan status:** ${statusLabel}\n`; + if (riskLabel) prompt += `**Risk level:** ${riskLabel}\n`; + if (planDescription) prompt += `\n**Plan context:** ${planDescription}\n`; + + const steps = Array.isArray(plan.steps) ? plan.steps : []; + if (steps.length > 0) { + prompt += '\n**Steps:**\n'; + for (const step of steps) { + const action = normalizeText(step.action) || `Step ${step.order}`; + const qualifiers = [ + formatIdentifierLabel(step.risk_level)?.toLowerCase() + ? `${formatIdentifierLabel(step.risk_level)?.toLowerCase()} risk` + : undefined, + step.command ? 'command recorded in governed plan' : undefined, + step.rollback_command ? 'rollback command recorded in governed plan' : undefined, + ].filter(isNonEmptyString); + prompt += `${step.order}. ${action}${qualifiers.length > 0 ? ` (${qualifiers.join('; ')})` : ''}\n`; + } + } + + const commandSummary = formatPlanCommandSummary(plan); + if (commandSummary) { + prompt += `\n**Governed action details:** ${commandSummary}.\n`; + } + prompt += + '\nCommand details stay in the remediation or approval surface. Do not infer, repeat, or execute raw command text from this chat handoff. If any step is risky or ambiguous, ask before proceeding.'; + return prompt; +} + +export function buildPatrolRemediationPlanAssistantBriefing( + input: PatrolRemediationPlanAssistantInput, +): AIChatContextBriefing { + const title = normalizeText(input.title) || 'Patrol finding'; + const subject = normalizeText(input.subject) || 'affected resource'; + const plan = input.plan; + const steps = Array.isArray(plan.steps) ? plan.steps : []; + const statusParts = [ + formatIdentifierLabel(plan.status), + formatIdentifierLabel(plan.risk_level) + ? `${formatIdentifierLabel(plan.risk_level)} risk` + : undefined, + ].filter(isNonEmptyString); + const planTitle = normalizeText(plan.title); + const planDescription = normalizeText(plan.description); + const commandSummary = formatPlanCommandSummary(plan); + const stepSummaries = steps + .map((step) => { + const action = normalizeText(step.action); + if (!action) return undefined; + const risk = formatIdentifierLabel(step.risk_level); + return risk ? `${action} (${risk} risk)` : action; + }) + .filter(isNonEmptyString) + .slice(0, 4); + + return { + sourceLabel: 'Pulse Patrol', + title: 'Remediation plan attached', + subject: `${title} on ${subject}`, + statusLabel: statusParts.join(' ยท ') || undefined, + detailLines: [ + planTitle ? `Plan: ${planTitle}` : undefined, + planDescription, + steps.length > 0 ? `${steps.length} planned step${steps.length === 1 ? '' : 's'}` : undefined, + ].filter(isNonEmptyString), + evidence: stepSummaries, + actionLabel: planTitle || undefined, + commandSummary, + safetyNote: commandSummary + ? 'Command details stay in governed remediation context; execution requires the approval flow.' + : 'Review the governed remediation context before execution.', + }; +} + export function buildPatrolAssistantFindingBriefing( input: PatrolAssistantFindingBriefingInput, ): AIChatContextBriefing | undefined { @@ -457,6 +552,29 @@ function formatCommandSummary(count: number): string | undefined { : `${count} commands recorded for approval context`; } +function formatPlanCommandSummary(plan: RemediationPlan): string | undefined { + const steps = Array.isArray(plan.steps) ? plan.steps : []; + const commandCount = steps.filter((step) => Boolean(step.command)).length; + const rollbackCount = steps.filter((step) => Boolean(step.rollback_command)).length; + if (commandCount === 0 && rollbackCount === 0) return undefined; + const parts: string[] = []; + if (commandCount > 0) { + parts.push( + commandCount === 1 + ? '1 command recorded for governed plan review' + : `${commandCount} commands recorded for governed plan review`, + ); + } + if (rollbackCount > 0) { + parts.push( + rollbackCount === 1 + ? '1 rollback command recorded' + : `${rollbackCount} rollback commands recorded`, + ); + } + return parts.join('; '); +} + function formatBriefingStringList( values: Array, limit: number,