Route Patrol approval handoffs through structured context

This commit is contained in:
rcourtman 2026-05-07 14:35:07 +01:00
parent 92ee9c3b1e
commit dd2ee25867
5 changed files with 211 additions and 132 deletions

View file

@ -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.

View file

@ -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

View file

@ -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

View file

@ -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<ApprovalSectionProps> = (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<ApprovalSectionProps> = (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

View file

@ -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');
});