Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
afd1181
feat(security-agent): move manual workflows into workers
jeanduplessis May 18, 2026
e51e73a
fix(security-agent): address reviewer feedback
jeanduplessis May 18, 2026
7327544
fix(security-agent): isolate DB integration tests
jeanduplessis May 18, 2026
d0bdf78
refactor(security-agent): decouple analysis callbacks
jeanduplessis May 18, 2026
ba7da35
fix(security-agent): revive terminal manual analysis rows
jeanduplessis May 19, 2026
e285eb6
refactor(security-agent): centralize analysis start lifecycle
jeanduplessis May 19, 2026
113662e
fix(security-agent): unify eligibility and dismissal parsing
jeanduplessis May 19, 2026
4fb5333
fix(security-agent): settle callbacks and stale queue rows
jeanduplessis May 19, 2026
38fb912
refactor(security-agent): align web with worker orchestration
jeanduplessis May 19, 2026
bb10b36
chore(workspace): restore main release age policy
jeanduplessis May 19, 2026
e0cf084
refactor(security-sync): wrap finding dismissal in a database transac…
kilo-code-bot[bot] Jun 1, 2026
e3db40e
fix(security-agent): harden analysis callback attempts
jeanduplessis Jun 1, 2026
334d738
chore: address code review feedback
jeanduplessis Jun 1, 2026
6b70289
test(cloud-agent-next): remove stale callback failure expectation
jeanduplessis Jun 1, 2026
2cf2a1b
fix(security-agent): address worker review feedback
jeanduplessis Jun 1, 2026
42c95e6
fix(security-agent): keep accepted poll through completion
jeanduplessis Jun 1, 2026
dd68e11
chore(security): add local dev worker group
jeanduplessis Jun 2, 2026
fd53415
fix(security-sync): accept nullable dependabot fields
jeanduplessis Jun 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions apps/web/.env.development.local.example
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ AUTO_FIX_URL=http://localhost:8792
# @url cloudflare-auto-triage-infra
AUTO_TRIAGE_URL=http://localhost:8791

# @url cloudflare-security-sync
SECURITY_SYNC_WORKER_URL=http://localhost:8812

# @url cloudflare-security-auto-analysis
SECURITY_AUTO_ANALYSIS_WORKER_URL=http://localhost:8797

# @url cloudflare-app-builder
APP_BUILDER_URL=http://localhost:8790

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,10 +105,18 @@ jest.mock('@/lib/utils.server', () => ({
}));

jest.mock('@/lib/drizzle', () => {
let selectedTable: unknown;
const chain = {
from: jest.fn().mockReturnThis(),
from: jest.fn((table: unknown) => {
selectedTable = table;
return chain;
}),
where: jest.fn().mockReturnThis(),
limit: jest.fn(() => mockDbSelect()),
limit: jest.fn(() =>
(selectedTable as { __name?: string } | undefined)?.__name === 'security_analysis_queue'
? Promise.resolve([{ claimToken: 'attempt-token-123' }])
: mockDbSelect()
),
};
return {
db: {
Expand All @@ -119,16 +127,25 @@ jest.mock('@/lib/drizzle', () => {

jest.mock('@kilocode/db/schema', () => ({
kilocode_users: { id: 'id' },
security_analysis_queue: {
__name: 'security_analysis_queue',
claim_token: 'claim_token',
finding_id: 'finding_id',
queue_status: 'queue_status',
},
}));

jest.mock('drizzle-orm', () => ({
and: jest.fn(),
eq: jest.fn(),
inArray: jest.fn(),
}));

// --- Helpers ---

const CALLBACK_SECRET = 'test-callback-token-secret';
const FINDING_ID = 'finding-abc-123';
const ATTEMPT_TOKEN = 'attempt-token-123';
let defaultCallbackToken: string;

function makeRequest(
Expand All @@ -137,6 +154,9 @@ function makeRequest(
callbackToken: string | null = defaultCallbackToken
): NextRequest {
return {
nextUrl: new URL(
`https://app.kilo.ai/api/internal/security-analysis-callback/${findingId}?attempt=${ATTEMPT_TOKEN}`
),
headers: {
get: (name: string) => {
if (name === 'X-Callback-Token') return callbackToken;
Expand Down Expand Up @@ -248,7 +268,7 @@ beforeEach(async () => {
defaultCallbackToken = await deriveCallbackToken({
secret: CALLBACK_SECRET,
scope: 'security-analysis-callback',
resourceParts: [FINDING_ID],
resourceParts: [FINDING_ID, ATTEMPT_TOKEN],
});
mockUpdateAnalysisStatus.mockResolvedValue(true);
mockTransitionAutoAnalysisQueueFromCallback.mockResolvedValue(undefined);
Expand Down Expand Up @@ -292,7 +312,7 @@ describe('POST /api/internal/security-analysis-callback/[findingId]', () => {
const callbackToken = await deriveCallbackToken({
secret: CALLBACK_SECRET,
scope: 'security-analysis-callback',
resourceParts: ['different-finding'],
resourceParts: ['different-finding', ATTEMPT_TOKEN],
});
const req = makeRequest(FINDING_ID, completedPayload, callbackToken);
const response = await POST(req, makeParams(FINDING_ID));
Expand Down Expand Up @@ -366,6 +386,7 @@ describe('POST /api/internal/security-analysis-callback/[findingId]', () => {
expect(body.message).toBe('Superseded finding ignored');
expect(mockTransitionAutoAnalysisQueueFromCallback).toHaveBeenCalledWith({
findingId: FINDING_ID,
attemptToken: ATTEMPT_TOKEN,
toStatus: 'completed',
failureCode: 'SKIPPED_NO_LONGER_ELIGIBLE',
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ import { fetchSessionSnapshot } from '@/lib/session-ingest-client';
import { trackSecurityAgentAnalysisCompleted } from '@/lib/security-agent/posthog-tracking';
import { generateApiToken } from '@/lib/tokens';
import { db } from '@/lib/drizzle';
import { kilocode_users } from '@kilocode/db/schema';
import { eq } from 'drizzle-orm';
import { kilocode_users, security_analysis_queue } from '@kilocode/db/schema';
import { and, eq, inArray } from 'drizzle-orm';
import { z } from 'zod';
import { verifyCallbackToken } from '@kilocode/worker-utils/callback-token';
import { logExceptInTest, sentryLogger } from '@/lib/utils.server';
Expand All @@ -32,6 +32,8 @@ import {
DEFAULT_SECURITY_AGENT_TRIAGE_MODEL,
} from '@/lib/security-agent/core/constants';

// Compatibility-only callback ingress retained for explicit rollback routing.
// Durable default ingress lives in the security-auto-analysis Worker.
const log = sentryLogger('security-agent:callback', 'info');
const warn = sentryLogger('security-agent:callback', 'warning');
const logError = sentryLogger('security-agent:callback', 'error');
Expand Down Expand Up @@ -82,14 +84,18 @@ export async function POST(
) {
try {
const { findingId } = await params;
const attemptToken = req.nextUrl.searchParams.get('attempt');
if (!attemptToken) {
return NextResponse.json({ error: 'Missing callback attempt token' }, { status: 400 });
}
const callbackToken = req.headers.get('X-Callback-Token');
const validCallbackToken =
!!CALLBACK_TOKEN_SECRET &&
(await verifyCallbackToken({
token: callbackToken,
secret: CALLBACK_TOKEN_SECRET,
scope: 'security-analysis-callback',
resourceParts: [findingId],
resourceParts: [findingId, attemptToken],
}));
if (!validCallbackToken) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
Expand Down Expand Up @@ -117,6 +123,28 @@ export async function POST(
return NextResponse.json({ error: 'Finding not found' }, { status: 404 });
}

const [activeAttempt] = await db
.select({ claimToken: security_analysis_queue.claim_token })
.from(security_analysis_queue)
.where(
and(
eq(security_analysis_queue.finding_id, findingId),
inArray(security_analysis_queue.queue_status, ['pending', 'running'])
)
)
.limit(1);
if (
(finding.analysis_status === 'pending' || finding.analysis_status === 'running') &&
activeAttempt?.claimToken !== attemptToken
) {
warn('Ignoring stale auto-analysis callback due to attempt mismatch', {
findingId,
callbackAttemptToken: attemptToken,
activeAttemptToken: activeAttempt?.claimToken ?? null,
});
return NextResponse.json({ success: true, message: 'Stale callback ignored' });
}

const sessionMismatch =
(payload.cloudAgentSessionId &&
finding.session_id &&
Expand Down Expand Up @@ -158,6 +186,7 @@ export async function POST(
});
await transitionAutoAnalysisQueueFromCallback({
findingId,
attemptToken,
toStatus: 'completed',
failureCode: 'SKIPPED_NO_LONGER_ELIGIBLE',
});
Expand All @@ -184,9 +213,9 @@ export async function POST(
after(async () => {
try {
if (payload.status === 'completed') {
await handleAnalysisCompleted(findingId, payload, finding);
await handleAnalysisCompleted(findingId, attemptToken, payload, finding);
} else if (payload.status === 'failed' || payload.status === 'interrupted') {
await handleAnalysisFailed(findingId, payload, finding);
await handleAnalysisFailed(findingId, attemptToken, payload, finding);
} else {
const unknownStatus = payload.status as string;
logError('Unknown callback status received, marking as failed', {
Expand All @@ -202,6 +231,7 @@ export async function POST(
}
await transitionAutoAnalysisQueueFromCallback({
findingId,
attemptToken,
toStatus: 'failed',
failureCode: 'STATE_GUARD_REJECTED',
errorMessage: `Unknown callback status: ${unknownStatus}`,
Expand Down Expand Up @@ -255,6 +285,7 @@ function readAnalysisContext(analysis: SecurityFindingAnalysis | null | undefine

async function handleAnalysisCompleted(
findingId: string,
attemptToken: string,
payload: ExecutionCallbackPayload,
finding: Awaited<ReturnType<typeof getSecurityFindingById>> & {}
) {
Expand All @@ -281,6 +312,7 @@ async function handleAnalysisCompleted(
}
await transitionAutoAnalysisQueueFromCallback({
findingId,
attemptToken,
toStatus: 'failed',
failureCode: 'STATE_GUARD_REJECTED',
errorMessage: 'Cannot process callback — triggeredByUserId missing from analysis context',
Expand All @@ -300,6 +332,7 @@ async function handleAnalysisCompleted(
}
await transitionAutoAnalysisQueueFromCallback({
findingId,
attemptToken,
toStatus: 'failed',
failureCode: 'STATE_GUARD_REJECTED',
errorMessage: 'Callback missing kiloSessionId — cannot retrieve analysis result',
Expand Down Expand Up @@ -365,6 +398,7 @@ async function handleAnalysisCompleted(
}
await transitionAutoAnalysisQueueFromCallback({
findingId,
attemptToken,
toStatus: 'failed',
failureCode: 'START_CALL_AMBIGUOUS',
errorMessage: 'Analysis completed but result could not be retrieved from ingest service',
Expand Down Expand Up @@ -404,6 +438,7 @@ async function handleAnalysisCompleted(
}
await transitionAutoAnalysisQueueFromCallback({
findingId,
attemptToken,
toStatus: 'failed',
failureCode: 'STATE_GUARD_REJECTED',
errorMessage: `User ${triggeredByUserId} not found — cannot run Tier 3 extraction`,
Expand Down Expand Up @@ -449,6 +484,7 @@ async function handleAnalysisCompleted(
});
await transitionAutoAnalysisQueueFromCallback({
findingId,
attemptToken,
toStatus: 'failed',
failureCode: 'START_CALL_AMBIGUOUS',
errorMessage: error instanceof Error ? error.message : String(error),
Expand All @@ -458,17 +494,23 @@ async function handleAnalysisCompleted(

const updatedFinding = await getSecurityFindingById(findingId);
if (updatedFinding?.analysis_status === 'completed') {
await transitionAutoAnalysisQueueFromCallback({ findingId, toStatus: 'completed' });
await transitionAutoAnalysisQueueFromCallback({
findingId,
attemptToken,
toStatus: 'completed',
});
} else if (updatedFinding?.analysis_status === 'failed') {
await transitionAutoAnalysisQueueFromCallback({
findingId,
attemptToken,
toStatus: 'failed',
failureCode: 'START_CALL_AMBIGUOUS',
errorMessage: updatedFinding.analysis_error ?? undefined,
});
} else {
await transitionAutoAnalysisQueueFromCallback({
findingId,
attemptToken,
toStatus: 'failed',
failureCode: 'STATE_GUARD_REJECTED',
errorMessage: `Unexpected post-finalize state: ${updatedFinding?.analysis_status ?? 'finding_not_found'}`,
Expand All @@ -478,6 +520,7 @@ async function handleAnalysisCompleted(

async function handleAnalysisFailed(
findingId: string,
attemptToken: string,
payload: ExecutionCallbackPayload,
finding: Awaited<ReturnType<typeof getSecurityFindingById>> & {}
) {
Expand Down Expand Up @@ -521,6 +564,7 @@ async function handleAnalysisFailed(
}
await transitionAutoAnalysisQueueFromCallback({
findingId,
attemptToken,
toStatus: 'failed',
failureCode: callbackFailure.failureCode,
errorMessage,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ import type { SecurityFinding } from '@kilocode/db/schema';
import { useTRPC } from '@/lib/trpc/utils';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import Link from 'next/link';
import { toast } from 'sonner';
import { manualAnalysisAdmissionCopy } from './manual-analysis-admission-copy';

type Severity = 'critical' | 'high' | 'medium' | 'low';

Expand Down Expand Up @@ -106,6 +108,7 @@ export function FindingDetailDialog({
const startOrgAnalysisMutation = useMutation(
trpc.organizations.securityAgent.startAnalysis.mutationOptions({
onSuccess: async () => {
toast.success(manualAnalysisAdmissionCopy.successTitle);
await queryClient.invalidateQueries();
},
})
Expand All @@ -115,6 +118,7 @@ export function FindingDetailDialog({
const startUserAnalysisMutation = useMutation(
trpc.securityAgent.startAnalysis.mutationOptions({
onSuccess: async () => {
toast.success(manualAnalysisAdmissionCopy.successTitle);
await queryClient.invalidateQueries();
},
})
Expand Down
Loading
Loading