Make Patrol finding prompts action-aware

This commit is contained in:
rcourtman
2026-05-07 11:11:16 +01:00
parent df0d8565a5
commit 35fbeee9dc
7 changed files with 139 additions and 4 deletions
@@ -467,7 +467,11 @@ the canonical monitored-system blocked payload.
drawer handoffs may include live pending-approval metadata only as safe
operator context: approval ID, status, risk, requested/expiry timestamps,
target label, generated approval summary, and command count are allowed, while
approval command payloads stay inside governed approval/remediation surfaces
approval command payloads stay inside governed approval/remediation surfaces,
and finding-level first-turn prompt emphasis may be derived from the same
bounded metadata only so Assistant starts by reviewing approval status, risk,
dry-run posture, proposed-fix posture, and safest next step without receiving
raw command or execution payloads
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
@@ -821,8 +821,9 @@ frontend primitive boundary.
model-only refresh, but the shared drawer stays a generic shell rather than
a Patrol summary prompt builder. The Patrol helper may turn those same safe
references into visible action labels, safety notes, and approval-aware
suggested prompts or first-turn prompt emphasis, but it must not expose raw
command or execution payloads.
suggested prompts or first-turn prompt emphasis for assessment and
finding-level handoffs, but it must not expose raw command or execution
payloads.
The
drawer may render a generic
context-briefing band from `frontend-modern/src/stores/aiChat.ts`, but
@@ -161,7 +161,11 @@ Patrol-specific presentation helpers.
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
posture. The initial visible prompt for a Patrol finding must lead with that
governed approval or proposed-fix review instruction when safe metadata is
attached, so the operator starts from approval status, risk, dry-run posture,
and safest-next-step review instead of generic incident discussion. 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
@@ -492,6 +492,10 @@ export const FindingsPanel: Component<FindingsPanelProps> = (props) => {
title,
subject,
description: finding.description,
investigationOutcome: finding.investigationOutcome,
remediationId: finding.remediationPlanId,
pendingApproval: pendingApprovalBriefing,
proposedFix,
investigationRecord: finding.investigationRecord,
});
const briefing = buildPatrolAssistantFindingBriefing({
@@ -48,6 +48,8 @@ describe('FindingsPanel assistant handoff', () => {
expect(findingsPanelSource).toContain('buildPatrolAssistantApprovalBriefingInput');
expect(findingsPanelSource).toContain('buildPatrolAssistantProposedFixBriefingInput');
expect(findingsPanelSource).toContain('briefing,');
expect(findingsPanelSource).toContain('investigationOutcome: finding.investigationOutcome');
expect(findingsPanelSource).toContain('remediationId: finding.remediationPlanId');
expect(findingsPanelSource).toContain('investigationRecord: finding.investigationRecord');
expect(findingsPanelSource).toContain('pendingApproval: pendingApprovalBriefing');
expect(findingsPanelSource).toContain('proposedFix,');
@@ -455,6 +455,61 @@ describe('patrolInvestigationContextModel', () => {
).toContain('Use that record as the main context before suggesting next actions.');
});
it('leads finding prompts with live governed approval context without command text', () => {
const prompt = buildPatrolAssistantFindingPrompt({
title: 'High CPU usage',
subject: 'web-server',
description: 'CPU stayed above 95%.',
pendingApproval: {
id: 'approval-1',
status: 'pending',
riskLevel: 'high',
targetName: 'web-server',
actionApprovalPolicy: 'operator',
actionPreflight: 'Service restart would be attempted after health checks.',
actionDryRunSummary: 'Dry run completed with one restart action.',
},
proposedFix: buildPatrolAssistantProposedFixBriefingInput({
description: 'Restart the workload service',
commands: ['systemctl restart workload.service'],
riskLevel: 'high',
targetHost: 'web-server',
}),
});
expect(prompt).toContain('Start by reviewing governed approval approval-1');
expect(prompt).toContain('approval status pending');
expect(prompt).toContain('high risk');
expect(prompt).toContain('approval policy attached');
expect(prompt).toContain('dry-run posture attached');
expect(prompt).toContain('safest next step');
expect(prompt).not.toContain('systemctl restart workload.service');
});
it('leads finding prompts with proposed-fix posture without command text', () => {
const prompt = buildPatrolAssistantFindingPrompt({
title: 'Nginx down',
subject: 'node-1',
description: 'The service stopped responding.',
investigationOutcome: 'fix_queued',
remediationId: 'remediation-1',
proposedFix: buildPatrolAssistantProposedFixBriefingInput({
description: 'Restart nginx',
commands: ['systemctl restart nginx'],
riskLevel: 'medium',
targetHost: 'node-1',
destructive: true,
}),
});
expect(prompt).toContain('Start by reviewing the governed proposed fix');
expect(prompt).toContain('medium risk');
expect(prompt).toContain('1 command recorded for approval context');
expect(prompt).toContain('destructive action');
expect(prompt).toContain('safest next step');
expect(prompt).not.toContain('systemctl restart nginx');
});
it('builds a drawer briefing for Assistant handoff without exposing raw commands', () => {
const approvalRequestedAt = new Date(Date.now() - 60_000).toISOString();
const approvalExpiresAt = new Date(Date.now() + 10 * 60_000).toISOString();
@@ -61,6 +61,10 @@ export interface PatrolAssistantFindingPromptInput {
title: string;
subject: string;
description: string;
investigationOutcome?: string | null;
remediationId?: string | null;
pendingApproval?: PatrolAssistantApprovalBriefingInput | null;
proposedFix?: PatrolAssistantProposedFixBriefingInput | null;
investigationRecord?: InvestigationRecord | null;
}
@@ -343,18 +347,79 @@ export function buildPatrolAssistantFindingPrompt(
const subject = normalizeText(input.subject) || 'the affected resource';
const description = normalizeText(input.description);
const hasRecord = Boolean(input.investigationRecord?.id);
const actionInstruction = buildPatrolAssistantFindingActionPromptInstruction(input);
let prompt = `I'd like to discuss this Patrol finding: "${title}" on ${subject}.`;
if (hasRecord) {
prompt +=
'\n\nPulse Patrol has a structured investigation record for this finding. Use that record as the main context before suggesting next actions.';
}
if (actionInstruction) {
prompt += `\n\n${actionInstruction}`;
}
if (description) {
prompt += `\n\n${description}`;
}
return prompt;
}
function buildPatrolAssistantFindingActionPromptInstruction(
input: PatrolAssistantFindingPromptInput,
): string | undefined {
const pendingApproval = normalizeApprovalBriefing(input.pendingApproval);
const record = buildPatrolInvestigationRecordPresentation(input.investigationRecord);
const proposedFix = record.proposedFix || normalizeProposedFixBriefing(input.proposedFix);
const remediationId = normalizeText(input.remediationId);
const outcome = normalizeText(input.investigationOutcome || input.investigationRecord?.outcome);
const normalizedOutcome = outcome.toLowerCase();
if (pendingApproval.id) {
const contextParts = [
pendingApproval.status ? `approval status ${pendingApproval.status}` : undefined,
pendingApproval.riskLevel ? `${pendingApproval.riskLevel} risk` : undefined,
pendingApproval.targetName
? `target ${truncateContextText(pendingApproval.targetName, 120)}`
: undefined,
pendingApproval.actionApprovalPolicy ? 'approval policy attached' : undefined,
pendingApproval.actionPreflight || pendingApproval.actionDryRunSummary
? 'dry-run posture attached'
: undefined,
].filter(isNonEmptyString);
return `Start by reviewing governed approval ${pendingApproval.id}${
contextParts.length > 0 ? ` (${contextParts.join('; ')})` : ''
}. Use the attached Patrol context to explain prerequisites, risk, and the safest next step before any execution.`;
}
const hasGovernedActionPosture = Boolean(
proposedFix ||
remediationId ||
normalizeText(input.investigationRecord?.approval_id) ||
GOVERNED_ACTION_OUTCOMES.has(normalizedOutcome),
);
if (!hasGovernedActionPosture) {
return undefined;
}
const contextParts = [
proposedFix?.description
? `proposed fix ${truncateContextText(proposedFix.description, 120)}`
: undefined,
proposedFix?.riskLabel ? `${proposedFix.riskLabel.toLowerCase()} risk` : undefined,
proposedFix?.targetHost
? `target ${truncateContextText(proposedFix.targetHost, 120)}`
: undefined,
proposedFix?.commandSummary,
proposedFix?.destructive ? 'destructive action' : undefined,
remediationId ? `remediation ${truncateContextText(remediationId, 120)}` : undefined,
outcome ? `outcome ${formatIdentifierLabel(outcome)?.toLowerCase() || outcome}` : undefined,
].filter(isNonEmptyString);
return `Start by reviewing the governed proposed fix or action posture${
contextParts.length > 0 ? ` (${contextParts.join('; ')})` : ''
}. Use the attached Patrol context to explain risk, prerequisites, and the safest next step without repeating command text.`;
}
export function buildPatrolAssessmentAssistantHandoff(
input: PatrolAssessmentAssistantHandoffInput,
): PatrolAssessmentAssistantHandoff {