diff --git a/packages/gateway/src/routes/guard.ts b/packages/gateway/src/routes/guard.ts new file mode 100644 index 0000000..30bf673 --- /dev/null +++ b/packages/gateway/src/routes/guard.ts @@ -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 { + 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; + 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> | 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(), + }); + }); +} diff --git a/packages/gateway/src/server.ts b/packages/gateway/src/server.ts index 0d5626e..d7c30b7 100644 --- a/packages/gateway/src/server.ts +++ b/packages/gateway/src/server.ts @@ -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);