Skip to content

Commit 55db5fc

Browse files
Habiruaclaude
andcommitted
feat: SHRIKE-310 Circuit breaker for all scan tools
Add shared CircuitBreaker singleton in circuitBreaker.ts. Wrap all 8 scan tool HTTP calls (scan_prompt, scan_response, scan_sql_query, scan_command, scan_file_write, scan_web_search, scan_a2a_message, scan_agent_card) with CB.execute() for fail-closed resilience when backend is down. Fix command tests with missing config mocks and CB passthrough. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 21bef32 commit 55db5fc

10 files changed

Lines changed: 403 additions & 140 deletions

File tree

src/tools/a2aMessage.ts

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
extractSpecializedInternalDetails,
1515
type SanitizedResponse,
1616
} from '../utils/responseFormatter.js';
17+
import { CircuitOpenError, scanCircuitBreaker } from '../utils/circuitBreaker.js';
1718

1819
export interface A2AMessageInput {
1920
message: string;
@@ -125,16 +126,18 @@ export async function scanA2AMessage(input: A2AMessageInput, customerId: string
125126
if (input.task_id) context.task_id = input.task_id;
126127
if (input.role) context.role = input.role;
127128

128-
const response = await fetch(`${config.backendUrl}/api/scan/specialized`, {
129-
method: 'POST',
130-
headers: getAuthHeaders(),
131-
body: JSON.stringify({
132-
content: input.message,
133-
content_type: 'a2a_message',
134-
context,
135-
}),
136-
signal: controller.signal,
137-
});
129+
const response = await scanCircuitBreaker.execute(() =>
130+
fetch(`${config.backendUrl}/api/scan/specialized`, {
131+
method: 'POST',
132+
headers: getAuthHeaders(),
133+
body: JSON.stringify({
134+
content: input.message,
135+
content_type: 'a2a_message',
136+
context,
137+
}),
138+
signal: controller.signal,
139+
})
140+
);
138141

139142
clearTimeout(timeoutId);
140143

@@ -186,7 +189,10 @@ export async function scanA2AMessage(input: A2AMessageInput, customerId: string
186189
clearTimeout(timeoutId);
187190

188191
let internalResult: A2AMessageResult;
189-
if (error instanceof Error && error.name === 'AbortError') {
192+
if (error instanceof CircuitOpenError) {
193+
console.error(`[a2a] ${requestId} circuit breaker OPEN — blocking (fail-closed)`);
194+
internalResult = createFailClosedResponse(Date.now() - startTime, 'Security service unavailable (circuit breaker open)', messageLength);
195+
} else if (error instanceof Error && error.name === 'AbortError') {
190196
console.warn(`A2A message scan timed out after ${config.scanTimeoutMs}ms, BLOCKING (fail-closed)`);
191197
internalResult = createFailClosedResponse(Date.now() - startTime, 'Analysis timeout', messageLength);
192198
} else {

src/tools/agentCard.ts

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
extractSpecializedInternalDetails,
1616
type SanitizedResponse,
1717
} from '../utils/responseFormatter.js';
18+
import { CircuitOpenError, scanCircuitBreaker } from '../utils/circuitBreaker.js';
1819

1920
export interface AgentCardInput {
2021
agent_card: string;
@@ -122,16 +123,18 @@ export async function scanAgentCard(input: AgentCardInput, customerId: string =
122123
context.verify_signature = 'true';
123124
}
124125

125-
const response = await fetch(`${config.backendUrl}/api/scan/specialized`, {
126-
method: 'POST',
127-
headers: getAuthHeaders(),
128-
body: JSON.stringify({
129-
content: input.agent_card,
130-
content_type: 'agent_card',
131-
context,
132-
}),
133-
signal: controller.signal,
134-
});
126+
const response = await scanCircuitBreaker.execute(() =>
127+
fetch(`${config.backendUrl}/api/scan/specialized`, {
128+
method: 'POST',
129+
headers: getAuthHeaders(),
130+
body: JSON.stringify({
131+
content: input.agent_card,
132+
content_type: 'agent_card',
133+
context,
134+
}),
135+
signal: controller.signal,
136+
})
137+
);
135138

136139
clearTimeout(timeoutId);
137140

@@ -183,7 +186,10 @@ export async function scanAgentCard(input: AgentCardInput, customerId: string =
183186
clearTimeout(timeoutId);
184187

185188
let internalResult: AgentCardResult;
186-
if (error instanceof Error && error.name === 'AbortError') {
189+
if (error instanceof CircuitOpenError) {
190+
console.error(`[card] ${requestId} circuit breaker OPEN — blocking (fail-closed)`);
191+
internalResult = createFailClosedResponse(Date.now() - startTime, 'Security service unavailable (circuit breaker open)', cardLength);
192+
} else if (error instanceof Error && error.name === 'AbortError') {
187193
console.warn(`Agent card scan timed out after ${config.scanTimeoutMs}ms, BLOCKING (fail-closed)`);
188194
internalResult = createFailClosedResponse(Date.now() - startTime, 'Analysis timeout', cardLength);
189195
} else {

src/tools/command.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,18 @@ vi.mock('../config.js', () => ({
1818
debug: false,
1919
},
2020
getAuthHeaders: () => ({ 'Content-Type': 'application/json', Authorization: 'Bearer test-key' }),
21+
getSessionId: () => 'test-session',
22+
getAgentId: () => 'test-agent',
23+
}));
24+
25+
// Mock circuit breaker to pass through (no state accumulation across tests)
26+
vi.mock('../utils/circuitBreaker.js', () => ({
27+
scanCircuitBreaker: {
28+
execute: async (fn: () => Promise<unknown>) => fn(),
29+
},
30+
CircuitOpenError: class CircuitOpenError extends Error {
31+
constructor(msg = 'Circuit breaker is open') { super(msg); this.name = 'CircuitOpenError'; }
32+
},
2133
}));
2234

2335
// Suppress console.error in tests
@@ -62,6 +74,11 @@ describe('scanCommand', () => {
6274
body: JSON.stringify({
6375
content: 'ls -la',
6476
content_type: 'command',
77+
context: {
78+
session_id: 'test-session',
79+
agent_id: 'test-agent',
80+
source_application: 'shrike-mcp',
81+
},
6582
}),
6683
}),
6784
);
@@ -138,6 +155,9 @@ describe('scanCommand', () => {
138155
shell: 'bash',
139156
working_directory: '/app',
140157
execution_context: 'production',
158+
session_id: 'test-session',
159+
agent_id: 'test-agent',
160+
source_application: 'shrike-mcp',
141161
},
142162
}),
143163
}),

src/tools/command.ts

Lines changed: 23 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
extractSpecializedInternalDetails,
1818
type SanitizedResponse,
1919
} from '../utils/responseFormatter.js';
20+
import { CircuitOpenError, scanCircuitBreaker } from '../utils/circuitBreaker.js';
2021

2122
export interface CommandInput {
2223
command: string;
@@ -176,21 +177,24 @@ export async function scanCommand(input: CommandInput, customerId: string = 'ano
176177
}
177178

178179
// Call backend specialized scan endpoint
179-
const response = await fetch(`${config.backendUrl}/api/scan/specialized`, {
180-
method: 'POST',
181-
headers: getAuthHeaders(),
182-
body: JSON.stringify({
183-
content: input.command,
184-
content_type: 'command',
185-
context: {
186-
...context,
187-
session_id: getSessionId(),
188-
agent_id: getAgentId(),
189-
source_application: 'shrike-mcp',
190-
},
191-
}),
192-
signal: controller.signal,
193-
});
180+
// Circuit breaker wraps the HTTP call — rejects immediately when backend is down.
181+
const response = await scanCircuitBreaker.execute(() =>
182+
fetch(`${config.backendUrl}/api/scan/specialized`, {
183+
method: 'POST',
184+
headers: getAuthHeaders(),
185+
body: JSON.stringify({
186+
content: input.command,
187+
content_type: 'command',
188+
context: {
189+
...context,
190+
session_id: getSessionId(),
191+
agent_id: getAgentId(),
192+
source_application: 'shrike-mcp',
193+
},
194+
}),
195+
signal: controller.signal,
196+
})
197+
);
194198

195199
clearTimeout(timeoutId);
196200

@@ -246,7 +250,10 @@ export async function scanCommand(input: CommandInput, customerId: string = 'ano
246250
clearTimeout(timeoutId);
247251

248252
let internalResult: CommandResult;
249-
if (error instanceof Error && error.name === 'AbortError') {
253+
if (error instanceof CircuitOpenError) {
254+
console.error(`[command] ${requestId} circuit breaker OPEN — blocking (fail-closed)`);
255+
internalResult = createFailClosedResponse(Date.now() - startTime, 'Security service unavailable (circuit breaker open)', commandLength);
256+
} else if (error instanceof Error && error.name === 'AbortError') {
250257
console.warn(`Command scan timed out after ${config.scanTimeoutMs}ms, BLOCKING (fail-closed)`);
251258
internalResult = createFailClosedResponse(Date.now() - startTime, 'Analysis timeout', commandLength);
252259
} else {

src/tools/fileWrite.ts

Lines changed: 40 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
extractSpecializedInternalDetails,
1818
type SanitizedResponse,
1919
} from '../utils/responseFormatter.js';
20+
import { CircuitOpenError, scanCircuitBreaker } from '../utils/circuitBreaker.js';
2021

2122
/**
2223
* Phase 8b: Client-side size limits for file writes.
@@ -204,21 +205,23 @@ export async function scanFileWrite(input: FileWriteInput, customerId: string =
204205
let highestSeverity: string | undefined;
205206
let totalConfidence = 1.0;
206207

207-
// Step 1: Scan the file path
208-
const pathResponse = await fetch(`${config.backendUrl}/api/scan/specialized`, {
209-
method: 'POST',
210-
headers: getAuthHeaders(), // Includes Authorization header if API key is set
211-
body: JSON.stringify({
212-
content: input.path,
213-
content_type: 'file_path',
214-
context: {
215-
session_id: getSessionId(),
216-
agent_id: getAgentId(),
217-
source_application: 'shrike-mcp',
218-
},
219-
}),
220-
signal: controller.signal,
221-
});
208+
// Step 1: Scan the file path (through circuit breaker)
209+
const pathResponse = await scanCircuitBreaker.execute(() =>
210+
fetch(`${config.backendUrl}/api/scan/specialized`, {
211+
method: 'POST',
212+
headers: getAuthHeaders(),
213+
body: JSON.stringify({
214+
content: input.path,
215+
content_type: 'file_path',
216+
context: {
217+
session_id: getSessionId(),
218+
agent_id: getAgentId(),
219+
source_application: 'shrike-mcp',
220+
},
221+
}),
222+
signal: controller.signal,
223+
})
224+
);
222225

223226
if (!pathResponse.ok) {
224227
clearTimeout(timeoutId);
@@ -246,22 +249,24 @@ export async function scanFileWrite(input: FileWriteInput, customerId: string =
246249
});
247250
}
248251

249-
// Step 2: Scan the file content (path + content together for context)
250-
const contentResponse = await fetch(`${config.backendUrl}/api/scan/specialized`, {
251-
method: 'POST',
252-
headers: getAuthHeaders(), // Includes Authorization header if API key is set
253-
body: JSON.stringify({
254-
content: input.path,
255-
content_type: 'file_content',
256-
context: {
257-
content: input.content, // Backend expects "content" key, not "file_content"
258-
session_id: getSessionId(),
259-
agent_id: getAgentId(),
260-
source_application: 'shrike-mcp',
261-
},
262-
}),
263-
signal: controller.signal,
264-
});
252+
// Step 2: Scan the file content (through circuit breaker)
253+
const contentResponse = await scanCircuitBreaker.execute(() =>
254+
fetch(`${config.backendUrl}/api/scan/specialized`, {
255+
method: 'POST',
256+
headers: getAuthHeaders(),
257+
body: JSON.stringify({
258+
content: input.path,
259+
content_type: 'file_content',
260+
context: {
261+
content: input.content,
262+
session_id: getSessionId(),
263+
agent_id: getAgentId(),
264+
source_application: 'shrike-mcp',
265+
},
266+
}),
267+
signal: controller.signal,
268+
})
269+
);
265270

266271
clearTimeout(timeoutId);
267272

@@ -333,7 +338,10 @@ export async function scanFileWrite(input: FileWriteInput, customerId: string =
333338
clearTimeout(timeoutId);
334339

335340
let internalResult: FileWriteResult;
336-
if (error instanceof Error && error.name === 'AbortError') {
341+
if (error instanceof CircuitOpenError) {
342+
console.error(`[file] ${requestId} circuit breaker OPEN — blocking (fail-closed)`);
343+
internalResult = createFailClosedResponse(Date.now() - startTime, 'Security service unavailable (circuit breaker open)', pathLength, contentLength, fileExtension);
344+
} else if (error instanceof Error && error.name === 'AbortError') {
337345
console.warn(`File scan timed out after ${config.scanTimeoutMs}ms, BLOCKING (fail-closed)`);
338346
internalResult = createFailClosedResponse(Date.now() - startTime, 'Analysis timeout', pathLength, contentLength, fileExtension);
339347
} else {

src/tools/scan.ts

Lines changed: 26 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
getRedactionSummary,
1919
type RedactionEntry,
2020
} from '../utils/piiRedactor.js';
21+
import { CircuitOpenError, scanCircuitBreaker } from '../utils/circuitBreaker.js';
2122

2223
/**
2324
* Phase 8b: Client-side size limits to fail fast before network round-trip.
@@ -433,24 +434,27 @@ export async function scanPrompt(input: ScanInput, customerId: string = 'anonymo
433434
}
434435

435436
try {
436-
// Use fetchWithRetry for cold-start resilience
437-
const response = await fetchWithRetry(
438-
`${config.backendUrl}/scan`,
439-
{
440-
method: 'POST',
441-
headers: getAuthHeaders(),
442-
body: JSON.stringify({
443-
prompt: promptForBackend,
444-
conversation_history: contextForBackend,
445-
scan_type: 'full',
446-
context: {
447-
session_id: getSessionId(),
448-
agent_id: getAgentId(),
449-
source_application: 'shrike-mcp',
450-
},
451-
}),
452-
},
453-
config.scanTimeoutMs
437+
// Circuit breaker wraps fetchWithRetry — if all retries fail, CB records a failure.
438+
// After failureThreshold consecutive failures, CB opens and rejects immediately.
439+
const response = await scanCircuitBreaker.execute(() =>
440+
fetchWithRetry(
441+
`${config.backendUrl}/scan`,
442+
{
443+
method: 'POST',
444+
headers: getAuthHeaders(),
445+
body: JSON.stringify({
446+
prompt: promptForBackend,
447+
conversation_history: contextForBackend,
448+
scan_type: 'full',
449+
context: {
450+
session_id: getSessionId(),
451+
agent_id: getAgentId(),
452+
source_application: 'shrike-mcp',
453+
},
454+
}),
455+
},
456+
config.scanTimeoutMs
457+
)
454458
);
455459

456460
if (!response.ok) {
@@ -479,7 +483,10 @@ export async function scanPrompt(input: ScanInput, customerId: string = 'anonymo
479483
let internalResult: ScanResult;
480484
let errorMessage = 'Scan error';
481485

482-
if (error instanceof Error) {
486+
if (error instanceof CircuitOpenError) {
487+
console.error(`[scan] ${requestId} circuit breaker OPEN — blocking (fail-closed)`);
488+
errorMessage = 'Security service unavailable (circuit breaker open)';
489+
} else if (error instanceof Error) {
483490
if (error.name === 'AbortError') {
484491
console.warn(`Scan timed out after ${config.scanTimeoutMs}ms, BLOCKING (fail-closed)`);
485492
errorMessage = 'Analysis timeout';

0 commit comments

Comments
 (0)