mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-24 04:07:16 +00:00
Keep Patrol plan handoffs command-free
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<FindingsPanelProps> = (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,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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<string | undefined>,
|
||||
limit: number,
|
||||
|
||||
Reference in New Issue
Block a user