feat: expose MAGATAMA guard scan API at /v1/guard (scan + health)

Standalone injection-defense endpoint for Flightdeck and other internal
clients: pattern scan + optional LLM judge, independent of the inline
completion-pipeline defense.
This commit is contained in:
Rene Fichtmueller 2026-06-12 19:38:05 +02:00
parent 9ee75c0bc1
commit 18b23d3c58
2 changed files with 138 additions and 0 deletions

View File

@ -0,0 +1,136 @@
/**
* MAGATAMA Guard scan endpoint (Layer-3 injection defense as a service).
*
* Exposes the gateway's prompt-injection defense to trusted internal
* clients (Flightdeck, Magatama Core, Switchblade) as a standalone
* scan API, independent of the inline completion-pipeline defense:
*
* GET /v1/guard/health mode + availability
* POST /v1/guard/scan { status: clear|review|blocked, findings, judge }
*
* The endpoint always returns a verdict, even when the inline defense
* mode is `off` callers ask explicitly, so scanning is always wanted.
*/
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
import { z } from 'zod';
import {
scanForInjection,
decideAction,
getInjectionMode,
llmJudge,
type InjectionMode,
type InjectionScanResult,
} from '../modules/injection-defense.js';
import { callOllama } from '../pipeline/llm-client.js';
import { logger } from '../observability/logger.js';
const ScanRequestSchema = z.object({
text: z.string().min(1).max(20_000),
projectId: z.string().max(200).optional(),
source: z.string().max(100).optional().default('external'),
});
type GuardStatus = 'clear' | 'review' | 'blocked';
interface GuardFinding {
severity: 'low' | 'medium' | 'high' | 'critical';
kind: string;
evidence: string;
patternId: string;
preview: string;
}
function toFindings(scan: InjectionScanResult): GuardFinding[] {
return scan.matches.map((match) => ({
severity: match.severity,
kind: match.category,
evidence: match.description,
patternId: match.id,
preview: match.matchPreview,
}));
}
export async function guardRoute(fastify: FastifyInstance): Promise<void> {
fastify.get('/health', async (_request: FastifyRequest, reply: FastifyReply) => {
return reply.send({
status: 'ok',
service: 'magatama-guard',
layer: 'gateway-injection-defense',
mode: getInjectionMode(),
judgeModel: process.env['LLM_JUDGE_MODEL'] || 'qwen2.5:3b',
time: new Date().toISOString(),
});
});
fastify.post('/scan', async (request: FastifyRequest, reply: FastifyReply) => {
let body: z.infer<typeof ScanRequestSchema>;
try {
body = ScanRequestSchema.parse(request.body);
} catch (err) {
return reply.status(400).send({
statusCode: 400,
error: 'Bad Request',
message: err instanceof z.ZodError ? err.errors[0]?.message : 'Invalid request body',
});
}
const startMs = Date.now();
const configuredMode = getInjectionMode();
// Explicit scan requests always get a verdict, even with inline defense off.
const mode: InjectionMode = configuredMode === 'off' ? 'block' : configuredMode;
const scan = scanForInjection(body.text);
const action = decideAction(mode, scan);
let judge: Awaited<ReturnType<typeof llmJudge>> | null = null;
let status: GuardStatus;
if (action === 'block') {
status = 'blocked';
} else if (action === 'llm_judge') {
try {
judge = await llmJudge(body.text, {
model: process.env['LLM_JUDGE_MODEL'] || 'qwen2.5:3b',
callLLM: async (req) => {
const resp = await callOllama(
{
model: req.model,
prompt: req.prompt,
system: req.system,
stream: false,
options: { temperature: 0, num_predict: 8, ...(req.options ?? {}) },
},
'fast',
);
return { response: resp.response };
},
});
status = judge.verdict === 'injection' ? 'blocked' : judge.verdict === 'uncertain' ? 'review' : 'clear';
} catch (err) {
logger.warn({ err }, 'guard scan: LLM judge failed, downgrading to review');
status = 'review';
}
} else {
// Below the block threshold: medium/high evidence still deserves review.
status = scan.matches.some((m) => m.severity === 'medium' || m.severity === 'high') ? 'review' : 'clear';
}
if (status !== 'clear') {
logger.info(
{ source: body.source, projectId: body.projectId, status, score: scan.score, matches: scan.matches.map((m) => m.id) },
'guard scan verdict',
);
}
return reply.send({
source: 'llm-gateway-injection-defense',
service: 'magatama-layer3',
status,
blocked: status === 'blocked',
score: scan.score,
mode,
findings: toFindings(scan),
judge,
latencyMs: Date.now() - startMs,
checkedAt: new Date().toISOString(),
});
});
}

View File

@ -5,6 +5,7 @@ import fastifyHelmet from '@fastify/helmet';
import { completionRoute } from './routes/completion.js'; import { completionRoute } from './routes/completion.js';
import { batchRoute } from './routes/batch.js'; import { batchRoute } from './routes/batch.js';
import { classifyRoute } from './routes/classify.js'; import { classifyRoute } from './routes/classify.js';
import { guardRoute } from './routes/guard.js';
import { healthRoute } from './routes/health.js'; import { healthRoute } from './routes/health.js';
import { metricsRoute } from './routes/metrics.js'; import { metricsRoute } from './routes/metrics.js';
import { reviewRoute } from './routes/review.js'; import { reviewRoute } from './routes/review.js';
@ -141,6 +142,7 @@ async function buildServer() {
}); });
await server.register(batchRoute, { prefix: '/v1' }); await server.register(batchRoute, { prefix: '/v1' });
await server.register(classifyRoute, { prefix: '/v1' }); await server.register(classifyRoute, { prefix: '/v1' });
await server.register(guardRoute, { prefix: '/v1/guard' });
await server.register(reviewRoute, { prefix: '/v1' }); await server.register(reviewRoute, { prefix: '/v1' });
await server.register(learningInsightsRoute, { prefix: '/v1' }); await server.register(learningInsightsRoute, { prefix: '/v1' });
await server.register(healthRoute); await server.register(healthRoute);