diff --git a/cloud-agent-next/src/auth.ts b/cloud-agent-next/src/auth.ts index 3ea7f8663..be8a58b82 100644 --- a/cloud-agent-next/src/auth.ts +++ b/cloud-agent-next/src/auth.ts @@ -17,6 +17,10 @@ export function validateKiloToken( ): | { success: true; userId: string; token: string; botId?: string } | { success: false; error: string } { + if (!secret) { + return { success: false, error: 'NEXTAUTH_SECRET is not configured on the worker' }; + } + // Check header exists and has Bearer format if (!authHeader) { return { success: false, error: 'Missing Authorization header' }; diff --git a/src/components/security-agent/AnalysisJobsCard.tsx b/src/components/security-agent/AnalysisJobsCard.tsx index 021e7575e..3228dd352 100644 --- a/src/components/security-agent/AnalysisJobsCard.tsx +++ b/src/components/security-agent/AnalysisJobsCard.tsx @@ -1,7 +1,6 @@ 'use client'; import { useState } from 'react'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { @@ -10,20 +9,21 @@ import { CheckCircle2, XCircle, Clock, - AlertCircle, ChevronLeft, ChevronRight, RotateCcw, Package, - ExternalLink, + RefreshCw, } from 'lucide-react'; -import Link from 'next/link'; import { useTRPC } from '@/lib/trpc/utils'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { formatDistanceToNow } from 'date-fns'; +import { differenceInDays, differenceInHours, differenceInMinutes } from 'date-fns'; import { toast } from 'sonner'; import { SeverityBadge } from './SeverityBadge'; -import type { SecurityFindingAnalysis } from '@/lib/security-agent/core/types'; +import { FindingDetailDialog } from './FindingDetailDialog'; +import { DismissFindingDialog, type DismissReason } from './DismissFindingDialog'; +import { cn } from '@/lib/utils'; +import type { SecurityFinding } from '@/db/schema'; type AnalysisJobsCardProps = { organizationId?: string; @@ -31,39 +31,50 @@ type AnalysisJobsCardProps = { }; type AnalysisStatus = 'pending' | 'running' | 'completed' | 'failed'; +type Severity = 'critical' | 'high' | 'medium' | 'low'; const statusConfig: Record< AnalysisStatus, { icon: React.ComponentType<{ className?: string }>; - variant: 'default' | 'secondary' | 'destructive' | 'outline'; label: string; + badgeClass: string; } > = { - pending: { icon: Clock, variant: 'secondary', label: 'Queued' }, - running: { icon: Loader2, variant: 'default', label: 'Analyzing' }, - completed: { icon: CheckCircle2, variant: 'default', label: 'Completed' }, - failed: { icon: XCircle, variant: 'destructive', label: 'Failed' }, + pending: { + icon: Clock, + label: 'Queued', + badgeClass: 'border-gray-500/30 bg-gray-500/20 text-gray-400', + }, + running: { + icon: Loader2, + label: 'Analyzing', + badgeClass: 'border-yellow-500/30 bg-yellow-500/20 text-yellow-400', + }, + completed: { + icon: CheckCircle2, + label: 'Completed', + badgeClass: 'border-green-500/30 bg-green-500/20 text-green-400', + }, + failed: { + icon: XCircle, + label: 'Failed', + badgeClass: 'border-red-500/30 bg-red-500/20 text-red-400', + }, }; const PAGE_SIZE = 10; -type AnalysisJob = { - id: string; - package_name: string; - severity: string; - repo_full_name: string; - title: string; - analysis_status: string | null; - analysis_started_at: Date | null; - analysis_completed_at: Date | null; - analysis_error: string | null; - analysis: SecurityFindingAnalysis | null; - session_id: string | null; - cli_session_id: string | null; -}; +function formatCompactTimeAgo(date: Date) { + const now = new Date(); + const days = Math.abs(differenceInDays(now, date)); + if (days >= 1) return `${days}d ago`; + const hours = Math.abs(differenceInHours(now, date)); + if (hours >= 1) return `${hours}h ago`; + const minutes = Math.abs(differenceInMinutes(now, date)); + return `${minutes}m ago`; +} -// Helper to detect GitHub integration errors from error messages function isGitHubIntegrationError(error: unknown): boolean { const message = error instanceof Error ? error.message : String(error); return ( @@ -71,19 +82,121 @@ function isGitHubIntegrationError(error: unknown): boolean { message.includes('GitHub installation') || message.includes('installation_id') || message.includes('Bad credentials') || - message.includes('Not Found') // GitHub API returns 404 for uninstalled apps + message.includes('Not Found') + ); +} + +// ─── Row sub-components ────────────────────────────────────────────────────── + +function StatusBadge({ status }: { status: AnalysisStatus | null }) { + if (!status) return null; + const info = statusConfig[status]; + const Icon = info.icon; + return ( + + + {info.label} + + ); +} + +function AnalysisJobRow({ + finding, + onRetry, + isRetrying, + retryDisabled, + onClick, +}: { + finding: SecurityFinding; + onRetry: (id: string) => void; + isRetrying: boolean; + retryDisabled: boolean; + onClick: () => void; +}) { + const status = finding.analysis_status as AnalysisStatus | null; + const canRetry = status === 'failed' || (status === 'completed' && finding.analysis); + + const time = + finding.analysis_completed_at && (status === 'completed' || status === 'failed') + ? formatCompactTimeAgo(new Date(finding.analysis_completed_at)) + : finding.analysis_started_at + ? formatCompactTimeAgo(new Date(finding.analysis_started_at)) + : null; + + return ( +
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onClick(); + } + }} + className={cn( + 'hover:bg-muted/50 grid w-full cursor-pointer grid-cols-[96px_1fr_80px_100px_16px] items-center gap-x-1.5 px-4 py-3 text-left transition-colors', + status === 'failed' && 'bg-red-500/5' + )} + > + {/* Severity */} +
+ +
+ + {/* Title + package */} +
+

{finding.title}

+ + + {finding.package_name} + +
+ + {/* Status + time */} +
+ + {time &&
{time}
} +
+ + {/* Action */} +
+ {canRetry && ( + + )} +
+ + {/* Detail chevron */} + +
); } +// ─── Main Component ───────────────────────────────────────────────────────── + export function AnalysisJobsCard({ organizationId, onGitHubError }: AnalysisJobsCardProps) { const [currentPage, setCurrentPage] = useState(1); const [startingAnalysisId, setStartingAnalysisId] = useState(null); + const [selectedFinding, setSelectedFinding] = useState(null); + const [detailDialogOpen, setDetailDialogOpen] = useState(false); + const [dismissDialogOpen, setDismissDialogOpen] = useState(false); const trpc = useTRPC(); const queryClient = useQueryClient(); const isOrg = !!organizationId; const offset = (currentPage - 1) * PAGE_SIZE; - // Fetch findings with analysis status (pending, running, completed, failed) const { data, isLoading, isFetching } = useQuery({ ...(isOrg ? trpc.organizations.securityAgent.listAnalysisJobs.queryOptions({ @@ -98,326 +211,236 @@ export function AnalysisJobsCard({ organizationId, onGitHubError }: AnalysisJobs refetchInterval: query => { const result = query.state.data; if (!result) return false; - const jobs = (result.jobs || []) as AnalysisJob[]; - const hasActiveJobs = jobs.some(j => - ['pending', 'running'].includes(j.analysis_status || '') + const findings = result.jobs || []; + const hasActiveJobs = findings.some(f => + ['pending', 'running'].includes(f.analysis_status || '') ); - return hasActiveJobs ? 5000 : false; // Poll every 5s if active jobs + return hasActiveJobs ? 5000 : false; }, }); - // Retry mutation for failed analyses (organization) + const handleMutationSuccess = async () => { + onGitHubError?.(null); + await queryClient.invalidateQueries(); + setStartingAnalysisId(null); + }; + + const handleMutationError = (error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + if (isGitHubIntegrationError(error)) { + onGitHubError?.(message); + toast.error('GitHub Integration Error', { + description: 'The GitHub App may have been uninstalled. Please check your integrations.', + }); + } else { + toast.error('Analysis Failed', { description: message, duration: 8000 }); + } + setStartingAnalysisId(null); + }; + const retryOrgMutation = useMutation( trpc.organizations.securityAgent.startAnalysis.mutationOptions({ - onSuccess: async () => { - onGitHubError?.(null); // Clear any previous error on success - await queryClient.invalidateQueries(); - setStartingAnalysisId(null); + onSuccess: handleMutationSuccess, + onError: handleMutationError, + }) + ); + + const retryUserMutation = useMutation( + trpc.securityAgent.startAnalysis.mutationOptions({ + onSuccess: handleMutationSuccess, + onError: handleMutationError, + }) + ); + + const handleRetry = (findingId: string) => { + setStartingAnalysisId(findingId); + // If the finding has triage data, retry only sandbox analysis to avoid redundant triage + const jobs = data?.jobs || []; + const finding = jobs.find(f => f.id === findingId); + const retrySandboxOnly = !!finding?.analysis?.triage && finding.analysis_status === 'failed'; + if (isOrg) { + retryOrgMutation.mutate({ organizationId: organizationId, findingId, retrySandboxOnly }); + } else { + retryUserMutation.mutate({ findingId, retrySandboxOnly }); + } + }; + + // Dismiss mutation (organization) + const dismissOrgMutation = useMutation( + trpc.organizations.securityAgent.dismissFinding.mutationOptions({ + onSuccess: () => { + toast.success('Finding dismissed'); + void queryClient.invalidateQueries(); + setDismissDialogOpen(false); + setDetailDialogOpen(false); + setSelectedFinding(null); }, onError: error => { - const message = error instanceof Error ? error.message : String(error); - if (isGitHubIntegrationError(error)) { - onGitHubError?.(message); // Set error at page level - toast.error('GitHub Integration Error', { - description: - 'The GitHub App may have been uninstalled. Please check your integrations.', - }); - } else { - toast.error('Analysis Failed', { - description: message, - duration: 8000, // 8 seconds for errors - }); - } - setStartingAnalysisId(null); + toast.error('Failed to dismiss finding', { description: error.message }); }, }) ); - // Retry mutation for failed analyses (user) - const retryUserMutation = useMutation( - trpc.securityAgent.startAnalysis.mutationOptions({ - onSuccess: async () => { - onGitHubError?.(null); // Clear any previous error on success - await queryClient.invalidateQueries(); - setStartingAnalysisId(null); + // Dismiss mutation (user) + const dismissUserMutation = useMutation( + trpc.securityAgent.dismissFinding.mutationOptions({ + onSuccess: () => { + toast.success('Finding dismissed'); + void queryClient.invalidateQueries(); + setDismissDialogOpen(false); + setDetailDialogOpen(false); + setSelectedFinding(null); }, onError: error => { - const message = error instanceof Error ? error.message : String(error); - if (isGitHubIntegrationError(error)) { - onGitHubError?.(message); // Set error at page level - toast.error('GitHub Integration Error', { - description: - 'The GitHub App may have been uninstalled. Please check your integrations.', - }); - } else { - toast.error('Analysis Failed', { - description: message, - duration: 8000, // 8 seconds for errors - }); - } - setStartingAnalysisId(null); + toast.error('Failed to dismiss finding', { description: error.message }); }, }) ); + const handleDismiss = (reason: DismissReason, comment?: string) => { + if (!selectedFinding) return; + if (isOrg && organizationId) { + dismissOrgMutation.mutate({ + organizationId, + findingId: selectedFinding.id, + reason, + comment, + }); + } else { + dismissUserMutation.mutate({ + findingId: selectedFinding.id, + reason, + comment, + }); + } + }; + + const handleRowClick = (finding: SecurityFinding) => { + setSelectedFinding(finding); + setDetailDialogOpen(true); + }; + + const handleOpenDismissDialog = () => { + setDetailDialogOpen(false); + setDismissDialogOpen(true); + }; + if (isLoading) { return ( - - - Analysis Jobs - Loading... - - +
+ +
); } - const jobs = (data?.jobs || []) as AnalysisJob[]; + const findings = data?.jobs || []; const total = data?.total || 0; const runningCount = data?.runningCount || 0; const concurrencyLimit = data?.concurrencyLimit || 3; const totalPages = Math.ceil(total / PAGE_SIZE); - const hasPrevious = currentPage > 1; - const hasNext = currentPage < totalPages; - - if (jobs.length === 0 && currentPage === 1) { - return ( - - - - - Analysis Jobs - - No analysis jobs yet - - -

- Analysis jobs will appear here when you analyze security findings. Click "Start - Analysis" on any finding to begin. -

-
-
- ); - } + const startItem = offset + 1; + const endItem = Math.min(offset + findings.length, total); + const isDismissing = isOrg ? dismissOrgMutation.isPending : dismissUserMutation.isPending; return ( - - -
-
- - - Analysis Jobs - - - {total > 0 ? ( - <> - Showing {offset + 1}-{Math.min(offset + jobs.length, total)} of {total} jobs - - ) : ( - 'No analysis jobs' - )} - -
- {/* Concurrency indicator */} - = concurrencyLimit ? 'destructive' : 'secondary'}> - {runningCount}/{concurrencyLimit} running - -
-
- -
- {jobs.map(job => { - const status = job.analysis_status as AnalysisStatus | null; - const statusInfo = status ? statusConfig[status] : null; - const StatusIcon = statusInfo?.icon || AlertCircle; - - return ( -
- {/* Status Icon */} -
- -
- - {/* Job Info */} -
-
-
-
- - {job.package_name} - -
-
- {job.repo_full_name} • {job.title} -
-
- - {/* Status Badge */} - {statusInfo && ( - - - {statusInfo.label} - - )} -
- - {/* Timestamps */} -
- {job.analysis_started_at && ( - - Started{' '} - {formatDistanceToNow(new Date(job.analysis_started_at), { - addSuffix: true, - })} - - )} - {/* Only show completed_at when status is completed or failed */} - {job.analysis_completed_at && - (status === 'completed' || status === 'failed') && ( - - Completed{' '} - {formatDistanceToNow(new Date(job.analysis_completed_at), { - addSuffix: true, - })} - - )} -
- - {/* Cloud Agent Session Link */} - {job.cli_session_id && ( -
- - - View agent session - -
- )} - - {/* Error Message */} - {job.analysis_error && ( -
Error: {job.analysis_error}
- )} - - {/* Analysis Result Summary with Re-run button */} - {status === 'completed' && job.analysis && ( -
- - Analysis Complete - - - View finding details for full analysis - - -
- )} - - {/* Retry Button for Failed */} - {status === 'failed' && ( -
- -
- )} -
-
- ); - })} +
+ {/* Summary bar */} +
+
+ + + {runningCount} Running + + + + {findings.filter(f => f.analysis_status === 'completed').length} Completed + + + + {findings.filter(f => f.analysis_status === 'failed').length} Failed +
+ = concurrencyLimit ? 'destructive' : 'secondary'}> + {runningCount}/{concurrencyLimit} capacity + +
- {/* Pagination Controls */} - {total > PAGE_SIZE && ( -
-
- Page {currentPage} of {totalPages} -
-
- - -
+ {/* Rows */} +
+ {findings.length === 0 ? ( +
+ +

No analysis jobs yet

+

+ Click "Start Analysis" on any finding to begin. +

+
+ ) : ( +
+ {findings.map(finding => ( + = concurrencyLimit} + onClick={() => handleRowClick(finding)} + /> + ))}
)} - - +
+ + {/* Pagination */} + {total > 0 && ( +
+

+ Showing {startItem}–{endItem} of {total} +

+
+ + + Page {currentPage} of {totalPages} + + +
+
+ )} + + {/* Finding Detail Dialog */} + + + {/* Dismiss Finding Dialog */} + +
); } diff --git a/src/components/security-agent/FindingDetailDialog.tsx b/src/components/security-agent/FindingDetailDialog.tsx index b6fc347c1..f0da882b7 100644 --- a/src/components/security-agent/FindingDetailDialog.tsx +++ b/src/components/security-agent/FindingDetailDialog.tsx @@ -116,14 +116,15 @@ export function FindingDetailDialog({ const isAnalyzing = startAnalysisMutation.isPending || analysisStatus === 'pending' || analysisStatus === 'running'; - const handleStartAnalysis = () => { + const handleStartAnalysis = ({ retrySandboxOnly }: { retrySandboxOnly?: boolean } = {}) => { if (isOrg) { startOrgAnalysisMutation.mutate({ organizationId: organizationId, findingId: finding.id, + retrySandboxOnly, }); } else { - startUserAnalysisMutation.mutate({ findingId: finding.id }); + startUserAnalysisMutation.mutate({ findingId: finding.id, retrySandboxOnly }); } }; @@ -261,6 +262,23 @@ export function FindingDetailDialog({ analysis={analysis} showSandboxReasoning={analysisStatus === 'running'} /> + {/* Show error + retry when triage succeeded but sandbox analysis failed */} + {analysisStatus === 'failed' && ( +
+

+ Codebase analysis failed: {analysisError || 'Unknown error'} +

+ +
+ )} {cliSessionId && (
handleStartAnalysis()} disabled={isAnalyzing} className="mt-2" > @@ -332,7 +350,7 @@ export function FindingDetailDialog({ + + + {finding.analysis_error || + finding.analysis?.triage?.needsSandboxReasoning || + 'Analysis failed'} + + + ) : ( - )} - {finding.dependabot_html_url && ( - e.stopPropagation()} - className="text-muted-foreground hover:text-foreground flex items-center gap-1 text-xs transition-colors" - > - - View on GitHub - - )} -
+ ) + ) : ( + + )}
+ + {/* Detail chevron */} +
); } diff --git a/src/components/security-agent/SecurityFindingsCard.tsx b/src/components/security-agent/SecurityFindingsCard.tsx index 8c779113c..b3091b20e 100644 --- a/src/components/security-agent/SecurityFindingsCard.tsx +++ b/src/components/security-agent/SecurityFindingsCard.tsx @@ -1,5 +1,17 @@ 'use client'; +import { formatDistanceToNow } from 'date-fns'; +import { + AlertCircle, + AlertTriangle, + CheckCircle2, + ChevronLeft, + ChevronRight, + Clock, + Loader2, + RefreshCw, + Settings2, +} from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Select, @@ -8,21 +20,9 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; -import { SecurityFindingRow } from './SecurityFindingRow'; -import { RepositoryFilter } from './RepositoryFilter'; -import { - RefreshCw, - ChevronLeft, - ChevronRight, - AlertTriangle, - CheckCircle2, - Loader2, - AlertCircle, - Settings2, - Clock, -} from 'lucide-react'; -import { formatDistanceToNow } from 'date-fns'; import type { SecurityFinding } from '@/db/schema'; +import { RepositoryFilter } from './RepositoryFilter'; +import { SecurityFindingRow } from './SecurityFindingRow'; type Repository = { id: number; @@ -74,7 +74,7 @@ type SecurityFindingsCardProps = { hasIntegration: boolean; onEnableClick: () => void; lastSyncTime?: string | null; - onStartAnalysis?: (findingId: string) => void; + onStartAnalysis?: (findingId: string, options?: { retrySandboxOnly?: boolean }) => void; startingAnalysisId?: string | null; isAdmin: boolean; }; @@ -188,7 +188,10 @@ export function SecurityFindingsCard({ {lastSyncTime && ( - Last synced {formatDistanceToNow(new Date(lastSyncTime), { addSuffix: true })} + Last synced{' '} + {formatDistanceToNow(new Date(lastSyncTime), { + addSuffix: true, + })} )} {isAdmin && ( diff --git a/src/lib/security-agent/core/schemas.ts b/src/lib/security-agent/core/schemas.ts index 836055d23..6a7f8caa3 100644 --- a/src/lib/security-agent/core/schemas.ts +++ b/src/lib/security-agent/core/schemas.ts @@ -234,6 +234,7 @@ export const AnalysisResponseLegacySchema = z.object({ export const StartAnalysisInputSchema = z.object({ findingId: z.string().uuid(), model: z.string().optional(), + retrySandboxOnly: z.boolean().optional(), // Skip triage, reuse existing triage data, retry only sandbox }); /** diff --git a/src/lib/security-agent/services/analysis-service.ts b/src/lib/security-agent/services/analysis-service.ts index 429805a3c..565298db9 100644 --- a/src/lib/security-agent/services/analysis-service.ts +++ b/src/lib/security-agent/services/analysis-service.ts @@ -19,7 +19,12 @@ import { import { generateApiToken } from '@/lib/tokens'; import { getSecurityFindingById } from '../db/security-findings'; import { updateAnalysisStatus } from '../db/security-analysis'; -import type { AnalysisMode, SecurityFindingAnalysis, SecurityReviewOwner } from '../core/types'; +import type { + AnalysisMode, + SecurityFindingAnalysis, + SecurityFindingTriage, + SecurityReviewOwner, +} from '../core/types'; import type { User, SecurityFinding } from '@/db/schema'; import { trackSecurityAgentAnalysisStarted, @@ -243,6 +248,8 @@ export async function startSecurityAnalysis(params: { githubToken?: string; model?: string; analysisMode?: AnalysisMode; + forceSandbox?: boolean; + retrySandboxOnly?: boolean; organizationId?: string; }): Promise<{ started: boolean; error?: string; triageOnly?: boolean }> { const { @@ -252,6 +259,8 @@ export async function startSecurityAnalysis(params: { githubToken, model = 'anthropic/claude-sonnet-4', analysisMode = 'auto', + forceSandbox = false, + retrySandboxOnly = false, organizationId, } = params; @@ -268,8 +277,25 @@ export async function startSecurityAnalysis(params: { return { started: false, error: 'Analysis already in progress' }; } - // Mark as pending - await updateAnalysisStatus(findingId, 'pending'); + // When retrying sandbox only, preserve existing triage data + const existingTriage = retrySandboxOnly ? finding.analysis?.triage : undefined; + if (retrySandboxOnly && !existingTriage) { + log('retrySandboxOnly requested but no existing triage found, falling back to full analysis', { + correlationId, + findingId, + }); + } + const skipTriage = retrySandboxOnly && !!existingTriage; + + // Mark as pending, preserving existing analysis (with triage) when retrying sandbox only. + // Coerce null → undefined so updateAnalysisStatus treats it as "not provided" and + // does not overwrite the analysis column with null (its `status === 'pending'` branch + // only clears `analysis` when `updates.analysis === undefined`). + if (skipTriage) { + await updateAnalysisStatus(findingId, 'pending', { analysis: finding.analysis ?? undefined }); + } else { + await updateAnalysisStatus(findingId, 'pending'); + } const analysisStartTime = Date.now(); @@ -277,57 +303,84 @@ export async function startSecurityAnalysis(params: { // Generate auth token for LLM calls const authToken = generateApiToken(user); - // ========================================================================= - // Tier 1: Quick Triage (always runs) - // ========================================================================= - log('Starting Tier 1 triage', { correlationId, findingId, model }); + let triage: SecurityFindingTriage; - trackSecurityAgentAnalysisStarted({ - distinctId: user.id, - userId: user.id, - organizationId, - findingId, - model, - analysisMode, - }); + if (skipTriage) { + // ========================================================================= + // Reuse existing triage (sandbox-only retry) + // ========================================================================= + triage = existingTriage; + log('Skipping Tier 1 triage, reusing existing triage for sandbox retry', { + correlationId, + findingId, + suggestedAction: triage.suggestedAction, + confidence: triage.confidence, + }); - const tier1Start = performance.now(); - const triage = await triageSecurityFinding({ - finding, - authToken, - model, - correlationId, - userId: user.id, - organizationId, - }); - const tier1DurationMs = Math.round(performance.now() - tier1Start); + trackSecurityAgentAnalysisStarted({ + distinctId: user.id, + userId: user.id, + organizationId, + findingId, + model, + analysisMode, + }); + } else { + // ========================================================================= + // Tier 1: Quick Triage (always runs) + // ========================================================================= + log('Starting Tier 1 triage', { correlationId, findingId, model }); - log('Triage complete', { - correlationId, - findingId, - durationMs: tier1DurationMs, - suggestedAction: triage.suggestedAction, - confidence: triage.confidence, - needsSandboxAnalysis: triage.needsSandboxAnalysis, - }); + trackSecurityAgentAnalysisStarted({ + distinctId: user.id, + userId: user.id, + organizationId, + findingId, + model, + analysisMode, + }); + + const tier1Start = performance.now(); + triage = await triageSecurityFinding({ + finding, + authToken, + model, + correlationId, + userId: user.id, + organizationId, + }); + const tier1DurationMs = Math.round(performance.now() - tier1Start); - addBreadcrumb({ - category: 'security-agent.triage', - message: `Triage outcome: ${triage.suggestedAction}`, - level: 'info', - data: { + log('Triage complete', { correlationId, findingId, + durationMs: tier1DurationMs, suggestedAction: triage.suggestedAction, confidence: triage.confidence, - needsSandbox: triage.needsSandboxAnalysis, - durationMs: tier1DurationMs, - }, - }); + needsSandboxAnalysis: triage.needsSandboxAnalysis, + }); + + addBreadcrumb({ + category: 'security-agent.triage', + message: `Triage outcome: ${triage.suggestedAction}`, + level: 'info', + data: { + correlationId, + findingId, + suggestedAction: triage.suggestedAction, + confidence: triage.confidence, + needsSandbox: triage.needsSandboxAnalysis, + durationMs: tier1DurationMs, + }, + }); + } - // Decide whether to run sandbox analysis based on analysis mode + // Decide whether to run sandbox analysis based on analysis mode and per-request overrides const runSandbox = - analysisMode === 'deep' || (analysisMode === 'auto' && triage.needsSandboxAnalysis); + forceSandbox || + skipTriage || + analysisMode === 'deep' || + (analysisMode === 'auto' && triage.needsSandboxAnalysis); if (!runSandbox) { // ========================================================================= diff --git a/src/routers/organizations/organization-security-agent-router.ts b/src/routers/organizations/organization-security-agent-router.ts index f5c02581a..3c23e9f3d 100644 --- a/src/routers/organizations/organization-security-agent-router.ts +++ b/src/routers/organizations/organization-security-agent-router.ts @@ -709,6 +709,7 @@ export const organizationSecurityAgentRouter = createTRPCRouter({ githubToken, model, analysisMode, + retrySandboxOnly: input.retrySandboxOnly, organizationId: input.organizationId, }); } catch (error) { diff --git a/src/routers/security-agent-router.ts b/src/routers/security-agent-router.ts index bc75d669a..7a81b79c0 100644 --- a/src/routers/security-agent-router.ts +++ b/src/routers/security-agent-router.ts @@ -683,6 +683,7 @@ export const securityAgentRouter = createTRPCRouter({ githubToken, model, analysisMode, + retrySandboxOnly: input.retrySandboxOnly, // Personal user - no organizationId }); } catch (error) {