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:
parent
9ee75c0bc1
commit
18b23d3c58
136
packages/gateway/src/routes/guard.ts
Normal file
136
packages/gateway/src/routes/guard.ts
Normal 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(),
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -5,6 +5,7 @@ import fastifyHelmet from '@fastify/helmet';
|
||||
import { completionRoute } from './routes/completion.js';
|
||||
import { batchRoute } from './routes/batch.js';
|
||||
import { classifyRoute } from './routes/classify.js';
|
||||
import { guardRoute } from './routes/guard.js';
|
||||
import { healthRoute } from './routes/health.js';
|
||||
import { metricsRoute } from './routes/metrics.js';
|
||||
import { reviewRoute } from './routes/review.js';
|
||||
@ -141,6 +142,7 @@ async function buildServer() {
|
||||
});
|
||||
await server.register(batchRoute, { prefix: '/v1' });
|
||||
await server.register(classifyRoute, { prefix: '/v1' });
|
||||
await server.register(guardRoute, { prefix: '/v1/guard' });
|
||||
await server.register(reviewRoute, { prefix: '/v1' });
|
||||
await server.register(learningInsightsRoute, { prefix: '/v1' });
|
||||
await server.register(healthRoute);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user