Keep Patrol approval handoffs command-free

This commit is contained in:
rcourtman
2026-05-07 00:51:07 +01:00
parent bf0e4e16ac
commit ba46a5c6e1
5 changed files with 67 additions and 8 deletions
@@ -2974,6 +2974,9 @@ that same handoff. Frontend Patrol handoff helpers may consume current pending
approval list payloads only as safe metadata for that visible briefing: approval
ID, status, risk, request/expiry timestamps, and target label are allowed, while
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 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
@@ -819,7 +819,11 @@ frontend primitive boundary.
When the feature helper adds live approval state to the generic drawer
briefing, it may pass only safe approval metadata into
`AIChatContextBriefing`; raw approval commands remain owned by the governed
approval/remediation panels.
approval/remediation panels. 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.
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
@@ -157,8 +157,11 @@ Patrol-specific presentation helpers.
expiry, and target label; it must 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. The assembled handoff must still pass
through the Assistant runtime's
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
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.
@@ -51,23 +51,24 @@ export const ApprovalSection: Component<ApprovalSectionProps> = (props) => {
) => {
e.stopPropagation();
const desc = approval?.context || fix?.description || 'No description available';
const command =
approval?.command || (fix?.commands && fix.commands.length > 0 ? fix.commands[0] : undefined);
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 proposed a fix. Please help me execute it.\n\n**Finding:** ${props.findingTitle || 'Unknown finding'} on ${props.resourceName || 'unknown resource'}\n**Proposed fix:** ${desc}`;
if (command) prompt += `\n**Command:** \`${command}\``;
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\nPlease execute this fix on the target agent.`;
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,
autonomousMode: false,
});
};
@@ -79,6 +80,7 @@ export const ApprovalSection: Component<ApprovalSectionProps> = (props) => {
targetType: props.resourceType,
targetId: props.resourceId,
findingId: props.findingId,
autonomousMode: false,
},
);
};
@@ -128,10 +128,57 @@ describe('ApprovalSection', () => {
targetType: 'host',
targetId: 'host-1',
findingId: 'finding-1',
autonomousMode: false,
},
);
});
it('opens Assistant from a pending Patrol approval without carrying raw command text', async () => {
state.pendingApprovals = [
{
id: 'approval-1',
toolId: 'investigation_fix',
command: 'systemctl restart nginx',
targetType: 'investigation',
targetId: 'finding-1',
targetName: 'node-1',
context: 'Restart the workload service',
riskLevel: 'high',
status: 'pending',
requestedAt: new Date().toISOString(),
expiresAt: new Date(Date.now() + 5 * 60_000).toISOString(),
},
];
render(() => (
<ApprovalSection
findingId="finding-1"
investigationOutcome="fix_queued"
findingTitle="CPU saturation"
resourceName="node-1"
resourceType="agent"
resourceId="agent-1"
/>
));
fireEvent.click(await screen.findByRole('button', { name: /fix with assistant/i }));
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).not.toContain('systemctl restart nginx');
expect(prompt).not.toContain('Please execute this fix');
expect(context).toEqual({
targetType: 'agent',
targetId: 'agent-1',
findingId: 'finding-1',
autonomousMode: false,
});
});
it('recreates and executes a queued fix when autofix is available', async () => {
state.hasAutoFix = true;
getInvestigationMock.mockResolvedValue(null);