From ba46a5c6e19442e22fe7eaef913afb91e92151ff Mon Sep 17 00:00:00 2001 From: rcourtman Date: Thu, 7 May 2026 00:51:07 +0100 Subject: [PATCH] Keep Patrol approval handoffs command-free --- .../v6/internal/subsystems/api-contracts.md | 3 ++ .../subsystems/frontend-primitives.md | 6 ++- .../subsystems/patrol-intelligence.md | 7 ++- .../src/components/patrol/ApprovalSection.tsx | 12 +++-- .../patrol/__tests__/ApprovalSection.test.tsx | 47 +++++++++++++++++++ 5 files changed, 67 insertions(+), 8 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index b6dc0c9ba..8463fd373 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 85518edec..82fbd8d9b 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md index 9f8e5895a..d4452b056 100644 --- a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md +++ b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md @@ -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. diff --git a/frontend-modern/src/components/patrol/ApprovalSection.tsx b/frontend-modern/src/components/patrol/ApprovalSection.tsx index 56a348312..7473bd406 100644 --- a/frontend-modern/src/components/patrol/ApprovalSection.tsx +++ b/frontend-modern/src/components/patrol/ApprovalSection.tsx @@ -51,23 +51,24 @@ export const ApprovalSection: Component = (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 = (props) => { targetType: props.resourceType, targetId: props.resourceId, findingId: props.findingId, + autonomousMode: false, }, ); }; diff --git a/frontend-modern/src/components/patrol/__tests__/ApprovalSection.test.tsx b/frontend-modern/src/components/patrol/__tests__/ApprovalSection.test.tsx index 27ec87263..6941f5b9e 100644 --- a/frontend-modern/src/components/patrol/__tests__/ApprovalSection.test.tsx +++ b/frontend-modern/src/components/patrol/__tests__/ApprovalSection.test.tsx @@ -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(() => ( + + )); + + 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);