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 { 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);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user