From b7df44e6ce2c13026118f2d24e68dffc098f59c8 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Thu, 7 May 2026 01:04:25 +0100 Subject: [PATCH] Require approval mode for governed Patrol finding handoffs --- .../v6/internal/subsystems/api-contracts.md | 5 ++- .../subsystems/frontend-primitives.md | 6 ++- .../subsystems/patrol-intelligence.md | 8 +++- .../src/components/AI/FindingsPanel.tsx | 29 +++++++----- .../AI/__tests__/FindingsPanel.test.ts | 4 ++ .../patrolInvestigationContextModel.test.ts | 45 +++++++++++++++++++ .../patrol/patrolInvestigationContextModel.ts | 31 +++++++++++++ 7 files changed, 114 insertions(+), 14 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index b6d51167e..db3642152 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -324,7 +324,10 @@ the canonical monitored-system blocked payload. 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 + surfaces. Frontend finding-discussion handoffs that carry any live approval, + proposed-fix, fix outcome, or remediation-plan reference must force a + request-local approval-required Assistant mode instead of inheriting the + user's persistent autonomous control setting. 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 diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index cfb68da99..2d7fe68b5 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -826,7 +826,11 @@ frontend primitive boundary. 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. + 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 diff --git a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md index cb7bf7db0..8581dff66 100644 --- a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md +++ b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md @@ -163,8 +163,12 @@ Patrol-specific presentation helpers. 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 + governed remediation or approval surface. Generic finding discussion + handoffs must also force request-local approval-required mode when the finding + already references a live approval, proposed fix, fix outcome, or remediation + plan, so default autonomous Assistant settings cannot bypass the Patrol + action-governance boundary. 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/AI/FindingsPanel.tsx b/frontend-modern/src/components/AI/FindingsPanel.tsx index 7c8a99a30..5f690afde 100644 --- a/frontend-modern/src/components/AI/FindingsPanel.tsx +++ b/frontend-modern/src/components/AI/FindingsPanel.tsx @@ -22,6 +22,7 @@ import { buildPatrolAssistantFindingPrompt, buildPatrolRemediationPlanAssistantBriefing, buildPatrolRemediationPlanAssistantPrompt, + patrolAssistantFindingHandoffRequiresApprovalMode, } from '@/features/patrol/patrolInvestigationContextModel'; import { useResources } from '@/hooks/useResources'; import { InvestigationSection, ApprovalSection } from '@/components/patrol'; @@ -427,6 +428,16 @@ export const FindingsPanel: Component = (props) => { const pendingApproval = aiIntelligenceStore.patrolPendingApprovals.find( (approval) => approval.toolId === 'investigation_fix' && approval.targetId === finding.id, ); + const pendingApprovalBriefing = pendingApproval + ? { + id: pendingApproval.id, + status: pendingApproval.status, + riskLevel: pendingApproval.riskLevel, + requestedAt: pendingApproval.requestedAt, + expiresAt: pendingApproval.expiresAt, + targetName: pendingApproval.targetName, + } + : undefined; const prompt = buildPatrolAssistantFindingPrompt({ title, subject, @@ -443,16 +454,13 @@ export const FindingsPanel: Component = (props) => { regressionCount: finding.regressionCount, lastRegressionAt: finding.lastRegressionAt, remediationId: finding.remediationPlanId, - pendingApproval: pendingApproval - ? { - id: pendingApproval.id, - status: pendingApproval.status, - riskLevel: pendingApproval.riskLevel, - requestedAt: pendingApproval.requestedAt, - expiresAt: pendingApproval.expiresAt, - targetName: pendingApproval.targetName, - } - : undefined, + pendingApproval: pendingApprovalBriefing, + investigationRecord: finding.investigationRecord, + }); + const requiresApprovalMode = patrolAssistantFindingHandoffRequiresApprovalMode({ + investigationOutcome: finding.investigationOutcome, + remediationId: finding.remediationPlanId, + pendingApproval: pendingApprovalBriefing, investigationRecord: finding.investigationRecord, }); aiChatStore.openWithPrompt(prompt, { @@ -460,6 +468,7 @@ export const FindingsPanel: Component = (props) => { targetId: finding.resourceId, findingId: finding.id, briefing, + autonomousMode: requiresApprovalMode ? false : undefined, }); }; diff --git a/frontend-modern/src/components/AI/__tests__/FindingsPanel.test.ts b/frontend-modern/src/components/AI/__tests__/FindingsPanel.test.ts index 1dd42a091..3c2c4840b 100644 --- a/frontend-modern/src/components/AI/__tests__/FindingsPanel.test.ts +++ b/frontend-modern/src/components/AI/__tests__/FindingsPanel.test.ts @@ -49,6 +49,10 @@ describe('FindingsPanel assistant handoff', () => { expect(findingsPanelSource).toContain('investigationRecord: finding.investigationRecord'); expect(findingsPanelSource).toContain('pendingApproval: pendingApproval'); expect(findingsPanelSource).toContain('await aiIntelligenceStore.loadPendingApprovals()'); + expect(findingsPanelSource).toContain('patrolAssistantFindingHandoffRequiresApprovalMode'); + expect(findingsPanelSource).toContain( + 'autonomousMode: requiresApprovalMode ? false : undefined', + ); }); it('routes remediation plan handoffs through the command-free Patrol handoff model', () => { diff --git a/frontend-modern/src/features/patrol/__tests__/patrolInvestigationContextModel.test.ts b/frontend-modern/src/features/patrol/__tests__/patrolInvestigationContextModel.test.ts index adbd31ea9..4e10ac7d4 100644 --- a/frontend-modern/src/features/patrol/__tests__/patrolInvestigationContextModel.test.ts +++ b/frontend-modern/src/features/patrol/__tests__/patrolInvestigationContextModel.test.ts @@ -8,6 +8,7 @@ import { buildPatrolInvestigationRecordPresentation, buildPatrolRemediationPlanAssistantBriefing, buildPatrolRemediationPlanAssistantPrompt, + patrolAssistantFindingHandoffRequiresApprovalMode, } from '../patrolInvestigationContextModel'; describe('patrolInvestigationContextModel', () => { @@ -280,6 +281,50 @@ describe('patrolInvestigationContextModel', () => { expect(JSON.stringify(briefing)).not.toContain('systemctl'); }); + it('forces approval-required Assistant mode for governed finding handoffs', () => { + expect( + patrolAssistantFindingHandoffRequiresApprovalMode({ + pendingApproval: { id: 'approval-1', status: 'pending' }, + }), + ).toBe(true); + expect( + patrolAssistantFindingHandoffRequiresApprovalMode({ + remediationId: 'plan-1', + }), + ).toBe(true); + expect( + patrolAssistantFindingHandoffRequiresApprovalMode({ + investigationOutcome: 'fix_queued', + }), + ).toBe(true); + expect( + patrolAssistantFindingHandoffRequiresApprovalMode({ + investigationRecord: { + id: 'record-1', + finding_id: 'finding-1', + subject: { resource_id: 'agent-1' }, + trigger: { detected_at: '2026-05-06T12:00:00Z' }, + status: 'completed', + evidence: [], + proposed_fix: { + id: 'fix-1', + description: 'Restart service', + commands: ['systemctl restart nginx'], + destructive: false, + }, + verification: [], + tools_used: [], + started_at: '2026-05-06T12:00:00Z', + }, + }), + ).toBe(true); + expect( + patrolAssistantFindingHandoffRequiresApprovalMode({ + investigationOutcome: 'needs_attention', + }), + ).toBe(false); + }); + it('builds an operator briefing from current finding facts before a Patrol record exists', () => { expect( buildPatrolAssistantFindingBriefing({ diff --git a/frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts b/frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts index 768ef0fb6..20386d231 100644 --- a/frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts +++ b/frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts @@ -70,6 +70,13 @@ export interface PatrolAssistantFindingBriefingInput { investigationRecord?: InvestigationRecord | null; } +export interface PatrolAssistantFindingModeInput { + investigationOutcome?: string | null; + remediationId?: string | null; + pendingApproval?: PatrolAssistantApprovalBriefingInput | null; + investigationRecord?: InvestigationRecord | null; +} + export interface PatrolRemediationPlanAssistantInput { title: string; subject: string; @@ -176,6 +183,21 @@ export function buildPatrolAssistantFindingPrompt( return prompt; } +export function patrolAssistantFindingHandoffRequiresApprovalMode( + input: PatrolAssistantFindingModeInput, +): boolean { + const pendingApproval = normalizeApprovalBriefing(input.pendingApproval); + if (pendingApproval.id) return true; + if (normalizeText(input.remediationId)) return true; + + const record = input.investigationRecord; + if (normalizeText(record?.approval_id)) return true; + if (record?.proposed_fix) return true; + + const outcome = normalizeText(input.investigationOutcome || record?.outcome).toLowerCase(); + return GOVERNED_ACTION_OUTCOMES.has(outcome); +} + export function buildPatrolRemediationPlanAssistantPrompt( input: PatrolRemediationPlanAssistantInput, ): string { @@ -629,3 +651,12 @@ const PATROL_TOOL_LABELS: Record = { 'metrics.history': 'Metrics history', 'ssh.exec': 'SSH exec', }; + +const GOVERNED_ACTION_OUTCOMES = new Set([ + 'fix_queued', + 'fix_executed', + 'fix_failed', + 'fix_verified', + 'fix_verification_failed', + 'fix_verification_unknown', +]);