60 KiB
Guardrail MCP Server Implementation Plan
Version: 1.2 Status: Planning (Security & Scalability Review Complete)
Overview
Build a Guardrail Platform that serves as a central authority for guardrail enforcement:
- Database-backed: All Markdown/guardrail data stored in PostgreSQL
- Caching Layer: Redis for rule caching and rate limiting
- Web UI: Browse and edit guardrails (replaces reading MD files directly)
- MCP Endpoint: TUI clients (Claude Code, OpenCode, etc.) connect for live validation
- Deployment: Runs as containers (see
.envfor deployment target)
Architecture
┌─────────────────────────────────────────────────────────────┐
│ Deployment Server │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Guardrail MCP Server Container │ │
│ │ ┌─────────────┐ ┌─────────────┐ │ │
│ │ │ MCP SSE │ │ Web UI │ │ │
│ │ │ :8080 │ │ :8081 │ (source of │ │
│ │ └──────┬──────┘ └──────┬──────┘ truth) │ │
│ │ │ │ │ │
│ │ └────────────────┘ │ │
│ │ │ │ │
│ │ ┌───────────────────┴───────────────────┐ │ │
│ │ │ Redis Cache │ │ │
│ │ │ - Active rules cache (TTL: 5m) │ │ │
│ │ │ - Rate limiting counters │ │ │
│ │ │ - Session tokens │ │ │
│ │ └─────────────────────────────────────┘ │ │
│ │ │ │ │
│ │ ┌───────────────────┴───────────────────┐ │ │
│ │ │ PostgreSQL │ │ │
│ │ │ - documents (edited via Web UI) │ │ │
│ │ │ - prevention_rules │ │ │
│ │ │ - failure_registry │ │ │
│ │ │ - projects │ │ │
│ │ └─────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────┘ │
│ │ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ One-Time Ingest (migration only): │ │
│ │ Markdown files → PostgreSQL (run once at setup) │ │
│ └─────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
│
┌─────────────┴─────────────┐
▼ ▼
┌─────────────────┐ ┌──────────────────┐
│ TUI Clients │ │ Web Browser │
│ (Claude Code, │ │ (View/Edit │
│ OpenCode) │ │ Guardrails) │
└─────────────────┘ └──────────────────┘
┌─────────────────┐ ┌──────────────────┐
│ IDE Clients │ │ IDE Clients │
│ (VS Code, │ │ (JetBrains, │
│ Cursor) │ │ Vim/Neovim) │
│ via Extension │ │ via LSP/HTTP) │
└─────────────────┘ └──────────────────┘
Components
| Component | Port | Purpose |
|---|---|---|
| MCP Server | 8080 | SSE endpoint for TUI clients (Claude Code, OpenCode) |
| Web UI | 8081 | Browser UI (localhost only, no auth needed) |
| IDE API | 8081 | IDE extensions endpoint (VS Code, JetBrains) |
| Health | 8081 | /health/live, /health/ready probes |
| Metrics | 8081 | /metrics Prometheus endpoint |
| PostgreSQL | internal | Data persistence |
| Redis | internal | Caching, rate limiting, sessions |
Note: Web UI runs in the same container as the server, so it communicates internally without API keys. Only external clients (MCP TUI clients, IDE extensions) require authentication.
Data Flow
- One-Time Ingest: Load all MD files into PostgreSQL (migration only)
- Web UI: Users browse/edit guardrails stored in database (source of truth)
- MCP Server: Validates tool calls from multiple concurrent TUI/IDE clients
- IDE API: HTTP endpoints for IDE extensions (VS Code, JetBrains, Vim)
- Caching: Active rules cached in Redis (5 min TTL)
Tech Stack
- Go 1.23+ - Server implementation
- mark3labs/mcp-go - MCP protocol
- Echo - HTTP framework for web UI
- PostgreSQL 16 - Database
- Redis 7 - Caching and rate limiting
- caarlos0/env - Configuration (no Viper)
- slog - Structured logging
- golang-migrate - Database migrations
Project Structure
cmd/
├── server/
│ └── main.go # MCP + Web server (always running)
└── ingest/
└── main.go # One-time migration tool (run once)
internal/
├── models/ # Data models
├── database/ # PostgreSQL operations + migrations
│ └── migrations/ # golang-migrate files
├── cache/ # Redis client
├── ingester/ # MD file ingestion
├── guardrails/ # Validation logic
├── web/ # HTTP server + UI
│ ├── handlers.go # REST API handlers
│ ├── middleware.go # Auth, CSRF, rate limiting
│ └── static/ # Embedded web UI
├── mcp/ # MCP protocol handlers
├── config/ # Configuration
└── version/
deploy/
├── Dockerfile
├── Dockerfile.ingest # Separate image for ingest job
└── podman-compose.yml
scripts/
└── ingest.sh
Database Schema
Tables
documents
CREATE TABLE documents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
slug VARCHAR(255) UNIQUE NOT NULL,
title VARCHAR(500) NOT NULL,
content TEXT NOT NULL,
search_vector tsvector,
category VARCHAR(50) NOT NULL CHECK (category IN ('workflow', 'standard', 'guide', 'reference')),
path VARCHAR(500) NOT NULL,
version INTEGER DEFAULT 1,
metadata JSONB DEFAULT '{}',
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
-- Indexes
CREATE INDEX idx_documents_category ON documents(category);
CREATE INDEX idx_documents_slug ON documents(slug);
CREATE INDEX idx_documents_updated ON documents(updated_at DESC);
CREATE INDEX idx_documents_search ON documents USING GIN(search_vector);
CREATE INDEX idx_documents_metadata ON documents USING GIN(metadata);
prevention_rules
CREATE TABLE prevention_rules (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
rule_id VARCHAR(50) UNIQUE NOT NULL CHECK (LENGTH(TRIM(rule_id)) > 0),
name VARCHAR(255) NOT NULL,
pattern TEXT NOT NULL,
pattern_hash VARCHAR(64), -- For exact-match pre-filtering
message TEXT NOT NULL,
severity VARCHAR(10) NOT NULL CHECK (severity IN ('error', 'warning', 'info')),
enabled BOOLEAN NOT NULL DEFAULT true,
document_id UUID REFERENCES documents(id) ON DELETE SET NULL,
category VARCHAR(50),
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
-- Indexes
CREATE INDEX idx_rules_document ON prevention_rules(document_id);
CREATE INDEX idx_rules_enabled ON prevention_rules(enabled) WHERE enabled = true;
CREATE INDEX idx_rules_severity ON prevention_rules(severity);
CREATE INDEX idx_rules_category ON prevention_rules(category);
CREATE INDEX idx_rules_covering ON prevention_rules(document_id, rule_id, name, severity, enabled)
INCLUDE (pattern, message);
failure_registry
CREATE TABLE failure_registry (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
failure_id VARCHAR(50) UNIQUE NOT NULL,
category VARCHAR(50) NOT NULL,
severity VARCHAR(10) NOT NULL CHECK (severity IN ('critical', 'high', 'medium', 'low')),
error_message TEXT NOT NULL,
root_cause TEXT,
affected_files TEXT[],
regression_pattern VARCHAR(255),
status VARCHAR(20) NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'resolved', 'deprecated')),
project_slug VARCHAR(100),
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
) PARTITION BY RANGE (created_at);
-- Indexes
CREATE INDEX idx_failures_status ON failure_registry(status);
CREATE INDEX idx_failures_category ON failure_registry(category);
CREATE INDEX idx_failures_created ON failure_registry(created_at DESC);
CREATE INDEX idx_failures_files ON failure_registry USING GIN(affected_files);
CREATE INDEX idx_failures_project ON failure_registry(project_slug);
CREATE INDEX idx_failures_covering ON failure_registry(status, created_at DESC, severity)
INCLUDE (failure_id, category, error_message);
projects
CREATE TABLE projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) UNIQUE NOT NULL,
slug VARCHAR(100) UNIQUE NOT NULL CHECK (LENGTH(TRIM(slug)) > 0),
guardrail_context TEXT,
active_rules VARCHAR(50)[],
metadata JSONB DEFAULT '{}',
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
-- Indexes
CREATE INDEX idx_projects_slug ON projects(slug);
CREATE INDEX idx_projects_active_rules ON projects USING GIN(active_rules);
CREATE INDEX idx_projects_metadata ON projects USING GIN(metadata);
schema_migrations (auto-created by golang-migrate)
CREATE TABLE schema_migrations (
version BIGINT PRIMARY KEY,
dirty BOOLEAN NOT NULL
);
Migration Files
internal/database/migrations/
├── 001_create_tables.up.sql
├── 001_create_tables.down.sql
├── 002_add_indexes.up.sql
├── 002_add_indexes.down.sql
├── 003_add_constraints.up.sql
├── 003_add_constraints.down.sql
├── 004_add_search_vector.up.sql
└── 004_add_search_vector.down.sql
MCP Protocol Specification
Transport
- Server-to-Client: SSE (Server-Sent Events) on
/mcp/v1/sse - Client-to-Server: HTTP POST on
/mcp/v1/message - Session Management: JWT tokens with 15-minute expiry
Session Initialization
Tool: guardrail_init_session
{
"name": "guardrail_init_session",
"description": "Initialize a validation session for a project",
"inputSchema": {
"type": "object",
"properties": {
"project_slug": { "type": "string" },
"agent_type": { "type": "string", "enum": ["claude-code", "opencode", "cursor", "other"] },
"client_version": { "type": "string" }
},
"required": ["project_slug"]
}
}
Response:
{
"session_token": "eyJhbGciOiJIUzI1NiIs...",
"expires_at": "2026-02-07T11:30:00Z",
"project_context": "...",
"active_rules_count": 15,
"capabilities": ["bash_validation", "git_validation", "edit_validation"]
}
JWT Configuration
// internal/auth/jwt.go
type JWTConfig struct {
Secret string `env:"JWT_SECRET,required"` // Min 32 bytes
RotationPeriod time.Duration `env:"JWT_ROTATION_HOURS,default=168"` // 7 days
Issuer string `env:"JWT_ISSUER,default=guardrail-mcp"`
Expiry time.Duration `env:"JWT_EXPIRY,default=15m"`
}
func ValidateJWTSecret(secret string) error {
if len(secret) < 32 {
return fmt.Errorf("JWT_SECRET must be at least 32 bytes, got %d", len(secret))
}
// Check entropy
var entropy float64
for _, b := range []byte(secret) {
entropy += float64(bits.OnesCount8(b))
}
if entropy/float64(len(secret)) < 3.5 {
return fmt.Errorf("JWT_SECRET has insufficient entropy")
}
return nil
}
Security Requirements:
- JWT_SECRET must be 32+ bytes with high entropy
- Use HS256 algorithm only (explicitly reject 'none')
- Tokens stored in Redis with 15-min TTL
- Implement token revocation list for logout
Validation Tools
1. guardrail_validate_bash
{
"name": "guardrail_validate_bash",
"description": "Validate bash command against forbidden patterns",
"inputSchema": {
"type": "object",
"properties": {
"session_token": { "type": "string" },
"command": { "type": "string" },
"working_directory": { "type": "string" }
},
"required": ["session_token", "command"]
}
}
2. guardrail_validate_file_edit
{
"name": "guardrail_validate_file_edit",
"description": "Validate file edit operation",
"inputSchema": {
"type": "object",
"properties": {
"session_token": { "type": "string" },
"file_path": { "type": "string" },
"old_string": { "type": "string" },
"new_string": { "type": "string" },
"change_description": { "type": "string" }
},
"required": ["session_token", "file_path", "old_string", "new_string"]
}
}
3. guardrail_validate_git_operation
{
"name": "guardrail_validate_git_operation",
"description": "Validate git command against guardrails",
"inputSchema": {
"type": "object",
"properties": {
"session_token": { "type": "string" },
"command": { "type": "string", "enum": ["push", "commit", "merge", "rebase", "reset"] },
"args": { "type": "array", "items": { "type": "string" } },
"is_force": { "type": "boolean" }
},
"required": ["session_token", "command"]
}
}
4. guardrail_validate_scope
{
"name": "guardrail_validate_scope",
"description": "Check if file path is in scope for session",
"inputSchema": {
"type": "object",
"properties": {
"session_token": { "type": "string" },
"file_path": { "type": "string" }
},
"required": ["session_token", "file_path"]
}
}
5. guardrail_pre_work_check
{
"name": "guardrail_pre_work_check",
"description": "Run pre-work checklist from failure registry",
"inputSchema": {
"type": "object",
"properties": {
"session_token": { "type": "string" },
"affected_files": { "type": "array", "items": { "type": "string" } }
},
"required": ["session_token", "affected_files"]
}
}
6. guardrail_batch_validate
{
"name": "guardrail_batch_validate",
"description": "Validate multiple operations at once",
"inputSchema": {
"type": "object",
"properties": {
"session_token": { "type": "string" },
"operations": {
"type": "array",
"items": {
"type": "object",
"properties": {
"tool": { "type": "string" },
"args": { "type": "object" }
}
}
}
},
"required": ["session_token", "operations"]
}
}
Response Format Standards
Success Response (no violations):
{
"valid": true,
"violations": [],
"meta": {
"checked_at": "2026-02-07T10:30:00Z",
"rules_evaluated": 15,
"duration_ms": 12,
"cached": true
}
}
Violation Response:
{
"valid": false,
"violations": [
{
"rule_id": "PREVENT-001",
"rule_name": "No Force Push",
"severity": "error",
"message": "git push --force violates guardrail: NO FORCE PUSH",
"category": "git_operation",
"action": "halt",
"suggested_alternative": "Use git push --force-with-lease instead",
"documentation_uri": "guardrail://docs/AGENT_GUARDRAILS"
}
],
"meta": {
"checked_at": "2026-02-07T10:30:00Z",
"rules_evaluated": 15,
"duration_ms": 12
}
}
Severity Actions:
| Severity | Action | Client Behavior |
|---|---|---|
| error | halt | MUST halt operation |
| warning | confirm | SHOULD show confirmation dialog |
| info | log | MAY log for awareness |
Error Response Format
{
"jsonrpc": "2.0",
"id": 1,
"error": {
"code": -32602,
"message": "Invalid guardrail parameters",
"data": {
"guardrail_error": {
"type": "validation_error",
"code": "INVALID_SESSION",
"message": "Session token expired or invalid",
"suggestion": "Call guardrail_init_session to create new session"
}
}
}
}
Error Codes:
| Code | Meaning | HTTP Equivalent |
|---|---|---|
| INVALID_SESSION | Session token invalid/expired | 401 |
| INVALID_API_KEY | API key invalid | 401 |
| RATE_LIMITED | Too many requests | 429 |
| RULE_VIOLATION | Guardrail violation found | 403 |
| INVALID_ARGUMENT | Bad parameters | 400 |
| INTERNAL_ERROR | Server error | 500 |
Resources
| Resource | Description |
|---|---|
guardrail://docs/{slug} |
Document content (markdown) |
guardrail://docs/search?q={query} |
Full-text search results |
guardrail://rules |
All prevention rules |
guardrail://rules/{rule_id} |
Specific rule |
guardrail://rules/active |
Active rules only |
guardrail://failures?status={status}&limit={n} |
Failure registry (paginated) |
guardrail://projects/{slug} |
Project configuration |
guardrail://projects/{slug}/active-rules |
Rules for project |
guardrail://quick-reference |
Quick reference card |
guardrail://health |
Health status |
guardrail://capabilities |
Server capabilities |
IDE Integration
IDE clients connect via HTTP API (not MCP protocol) for real-time validation.
IDE API Endpoints (Port 8081)
Health Check
GET /ide/health
Authorization: Bearer {MCP_API_KEY}
Validate File (on save)
POST /ide/validate/file
Authorization: Bearer {MCP_API_KEY}
Content-Type: application/json
{
"file_path": "src/main.go",
"content": "package main\n...",
"language": "go",
"project_slug": "my-project"
}
Response:
{
"valid": false,
"violations": [
{
"rule_id": "PREVENT-001",
"line": 42,
"column": 15,
"severity": "error",
"message": "Hardcoded secret detected",
"suggestion": "Use environment variable"
}
]
}
Validate Selection (real-time)
POST /ide/validate/selection
Authorization: Bearer {MCP_API_KEY}
{
"code": "rm -rf /",
"language": "bash",
"context": "cleanup script"
}
Get Active Rules for Project
GET /ide/rules?project=my-project
Authorization: Bearer {MCP_API_KEY}
Get Quick Reference
GET /ide/quick-reference
Authorization: Bearer {MCP_API_KEY}
IDE Extension Architecture
VS Code Extension
// vscode-guardrail/src/extension.ts
export function activate(context: vscode.ExtensionContext) {
const validator = new GuardrailValidator({
serverUrl: config.get('guardrail.serverUrl'),
apiKey: config.get('guardrail.apiKey'),
projectSlug: config.get('guardrail.project')
});
// Validate on save
vscode.workspace.onDidSaveTextDocument(doc => {
validator.validateFile(doc);
});
// Real-time diagnostics
vscode.languages.registerCodeActionsProvider('*', {
provideCodeActions: (doc, range) => {
return validator.validateSelection(doc.getText(range));
}
});
}
JetBrains Plugin
// GuardrailInspectionTool.kt
class GuardrailInspectionTool : LocalInspectionTool() {
override fun checkFile(
file: PsiFile,
manager: InspectionManager,
isOnTheFly: Boolean
): Array<ProblemDescriptor> {
val violations = GuardrailClient.validateFile(
path = file.virtualFile.path,
content = file.text,
language = file.language.id
)
return violations.map { v ->
manager.createProblemDescriptor(
file.findElementAt(v.offset),
v.message,
QuickFixes.get(v.ruleId),
v.severity.toHighlightSeverity(),
isOnTheFly
)
}.toTypedArray()
}
}
Vim/Neovim (via LSP or HTTP)
-- nvim-guardrail/lua/guardrail.lua
local M = {}
M.validate = function()
local buf = vim.api.nvim_get_current_buf()
local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false)
local content = table.concat(lines, '\n')
local response = http.post('http://guardrail-server:8081/ide/validate/file', {
file_path = vim.fn.expand('%:p'),
content = content,
language = vim.bo.filetype
})
-- Populate quickfix list
vim.fn.setqflist(response.violations)
vim.cmd('copen')
end
return M
WebSocket for Real-time
For IDEs requiring real-time validation:
WS /ide/ws/validate
Authorization: Bearer {MCP_API_KEY}
Messages:
- Client -> Server: { "type": "validate", "code": "...", "language": "go" }
- Server -> Client: { "type": "result", "violations": [...] }
Web UI REST API
Authentication
All endpoints require Authorization: Bearer {WEB_API_KEY} header.
Documents
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/documents |
List documents (paginated) |
| GET | /api/documents/:id |
Get document |
| PUT | /api/documents/:id |
Update document |
| GET | /api/documents/search?q={query} |
Full-text search (parameterized) |
| GET | /api/documents/category/{category} |
Filter by category |
Search Implementation (SQL Injection Safe):
// internal/database/documents.go
func (db *DB) SearchDocuments(ctx context.Context, query string, limit int) ([]Document, error) {
// Validate and sanitize query first
safeQuery, err := sanitizeSearchQuery(query)
if err != nil {
return nil, fmt.Errorf("invalid search query: %w", err)
}
// Use parameterized query - NEVER concatenate
rows, err := db.QueryContext(ctx, `
SELECT id, slug, title, content, category, path
FROM documents
WHERE search_vector @@ plainto_tsquery('english', $1)
ORDER BY ts_rank(search_vector, plainto_tsquery('english', $1)) DESC
LIMIT $2
`, safeQuery, limit)
if err != nil {
return nil, err
}
defer rows.Close()
// ... scan rows
}
func sanitizeSearchQuery(query string) (string, error) {
// Limit length
if len(query) > 200 {
return "", fmt.Errorf("query too long (max 200 chars)")
}
// Remove dangerous characters - only allow safe FTS operators
// Allow: alphanumeric, spaces, - (negation), * (prefix), " (phrase), & | (AND/OR)
safe := regexp.MustCompile(`[^a-zA-Z0-9\s\-\*"&\|]`)
cleaned := safe.ReplaceAllString(query, "")
// Prevent FTS operator injection
if strings.Count(cleaned, "(") != strings.Count(cleaned, ")") {
return "", fmt.Errorf("mismatched parentheses")
}
return cleaned, nil
}
Rules
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/rules |
List rules |
| GET | /api/rules/:id |
Get rule |
| POST | /api/rules |
Create rule |
| PUT | /api/rules/:id |
Update rule |
| DELETE | /api/rules/:id |
Delete rule |
| POST | /api/rules/:id/toggle |
Enable/disable rule |
| POST | /api/rules/:id/test |
Test rule against input |
Projects
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/projects |
List projects |
| GET | /api/projects/:slug |
Get project |
| POST | /api/projects |
Create project |
| PUT | /api/projects/:slug |
Update project |
| DELETE | /api/projects/:slug |
Delete project |
Failures
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/failures |
List failures (paginated) |
| GET | /api/failures/:id |
Get failure |
| POST | /api/failures |
Log new failure |
| PUT | /api/failures/:id |
Update failure status |
System
| Method | Endpoint | Description |
|---|---|---|
| GET | /health/live |
Liveness probe (process check) |
| GET | /health/ready |
Readiness probe (DB + Redis check) |
| GET | /metrics |
Prometheus metrics |
| GET | /api/stats |
Usage statistics |
| POST | /api/ingest |
Trigger ingest job |
Health Check Implementation
// internal/web/handlers/health.go
func HealthLive(c echo.Context) error {
// Simple process check
return c.JSON(200, map[string]string{"status": "alive"})
}
func HealthReady(c echo.Context) error {
// Check DB and Redis connectivity
if err := db.Ping(); err != nil {
return c.JSON(503, map[string]string{"status": "not ready", "reason": "database"})
}
if err := redis.Ping(); err != nil {
return c.JSON(503, map[string]string{"status": "not ready", "reason": "cache"})
}
return c.JSON(200, map[string]string{"status": "ready"})
}
Graceful Shutdown
// cmd/server/main.go
func main() {
srv := &http.Server{
Addr: ":8080",
Handler: e,
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 120 * time.Second,
}
// Start server in goroutine
go func() { srv.ListenAndServe() }()
// Wait for shutdown signal
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGTERM, syscall.SIGINT)
<-quit
// Graceful shutdown with timeout
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
srv.Shutdown(ctx)
}
CSRF Protection
- All state-changing operations require CSRF token
- Token provided in
X-CSRF-Tokenheader or cookie - Double-submit cookie pattern
Content Security Policy (CSP)
Strict CSP for Web UI:
// internal/web/middleware/security.go
func SecurityHeaders() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
// Strict CSP - no inline scripts allowed
csp := []string{
"default-src 'self'",
"script-src 'self'",
"style-src 'self' 'unsafe-inline'", // Allow inline styles (UI frameworks)
"img-src 'self' data:",
"font-src 'self'",
"connect-src 'self'",
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self'",
}
c.Response().Header().Set("Content-Security-Policy", strings.Join(csp, "; "))
// Additional security headers
c.Response().Header().Set("X-Content-Type-Options", "nosniff")
c.Response().Header().Set("X-Frame-Options", "DENY")
c.Response().Header().Set("X-XSS-Protection", "1; mode=block")
c.Response().Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
c.Response().Header().Set("Permissions-Policy", "accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()")
return next(c)
}
}
}
Nonce-Based CSP for Inline Scripts:
func CSPWithNonce() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
// Generate nonce for this request
nonce := generateNonce()
c.Set("csp_nonce", nonce)
csp := fmt.Sprintf("script-src 'nonce-%s' 'self'; style-src 'self' 'unsafe-inline'", nonce)
c.Response().Header().Set("Content-Security-Policy", csp)
return next(c)
}
}
}
Security Requirements
API Authentication
// internal/web/middleware/auth.go
// Key types: "mcp", "ide" (web UI is internal, no key needed)
func APIKeyAuth(allowedTypes ...string) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
auth := c.Request().Header.Get("Authorization")
if auth == "" {
return echo.NewHTTPError(401, "Missing authorization")
}
token := strings.TrimPrefix(auth, "Bearer ")
keyType := validateAPIKey(token)
if keyType == "" {
return echo.NewHTTPError(401, "Invalid API key")
}
// Check if key type is allowed for this endpoint
if !slices.Contains(allowedTypes, keyType) {
return echo.NewHTTPError(403, "API key type not allowed for this endpoint")
}
// Log hashed key only
slog.Info("API request",
"key_type", keyType,
"key_hash", hashKey(token),
"path", c.Path())
c.Set("key_type", keyType)
return next(c)
}
}
}
func hashKey(key string) string {
h := sha256.Sum256([]byte(key))
return hex.EncodeToString(h[:8])
}
Rate Limiting (Distributed with Redis)
// internal/web/middleware/ratelimit.go
// In-memory rate limiting doesn't work across multiple instances
// Use Redis for distributed rate limiting
type DistributedLimiter struct {
redis *redis.Client
window time.Duration
}
func NewDistributedLimiter(redis *redis.Client) *DistributedLimiter {
return &DistributedLimiter{
redis: redis,
window: time.Minute,
}
}
func (dl *DistributedLimiter) Allow(ctx context.Context, key string, limit int) bool {
// Sliding window counter in Redis
now := time.Now().Unix()
windowKey := fmt.Sprintf("ratelimit:%s:%d", key, now/60)
pipe := dl.redis.Pipeline()
incr := pipe.Incr(ctx, windowKey)
pipe.Expire(ctx, windowKey, dl.window)
_, err := pipe.Exec(ctx)
if err != nil {
// Fail open on Redis error
return true
}
return incr.Val() <= int64(limit)
}
// Usage per endpoint type:
// MCP: 1000/min per API key
// IDE: 500/min per API key
// Session: 100/min per session token
SSE Connection Limits
// internal/mcp/server.go
const (
MaxSSEConnections = 100 // Per instance
MaxConnectionsPerKey = 10 // Per API key
)
var (
activeConnections = make(map[string]int) // key -> count
connectionsMutex sync.RWMutex
)
func CanAcceptConnection(apiKeyHash string) bool {
connectionsMutex.Lock()
defer connectionsMutex.Unlock()
total := 0
for _, count := range activeConnections {
total += count
}
if total >= MaxSSEConnections {
return false
}
if activeConnections[apiKeyHash] >= MaxConnectionsPerKey {
return false
}
activeConnections[apiKeyHash]++
return true
}
// For horizontal scaling, use Redis to track global connections:
// Redis key: "sse:connections:{instance_id}" -> count
// Global limit checked before accepting
Database Connection Pool
// internal/database/postgres.go - Connection Pool
func NewDB(cfg *config.Config) (*sql.DB, error) {
db, err := sql.Open("pgx", connString)
if err != nil {
return nil, err
}
// Scale based on CPU cores - need 50+ for 1000 sessions
maxConns := 4 * runtime.NumCPU()
if maxConns < 50 {
maxConns = 50
}
db.SetMaxOpenConns(maxConns) // 50+ for 1000 sessions
db.SetMaxIdleConns(maxConns / 2) // 25 idle
db.SetConnMaxLifetime(15 * time.Minute) // Longer for stability
db.SetConnMaxIdleTime(5 * time.Minute)
return db, nil
}
Circuit Breakers
// internal/circuitbreaker/breaker.go
package circuitbreaker
import "github.com/sony/gobreaker"
var DBBreaker = gobreaker.NewCircuitBreaker(gobreaker.Settings{
Name: "database",
MaxRequests: 3, // Half-open state probe count
Interval: 10 * time.Second, // Statistical window
Timeout: 30 * time.Second, // Request timeout
ReadyToTrip: func(counts gobreaker.Counts) bool {
failureRatio := float64(counts.TotalFailures) / float64(counts.Requests)
return counts.Requests >= 3 && failureRatio >= 0.6
},
})
var RedisBreaker = gobreaker.NewCircuitBreaker(gobreaker.Settings{
Name: "redis",
Interval: 10 * time.Second,
Timeout: 5 * time.Second,
})
CSRF Protection
// internal/web/middleware/csrf.go
func CSRF() echo.MiddlewareFunc {
return csrfMiddleware(csrf.Config{
TokenLookup: "header:X-CSRF-Token",
CookieName: "csrf_token",
CookieSameSite: http.SameSiteStrictMode,
})
}
Container Security
# deploy/Dockerfile
FROM golang:1.23-alpine AS builder
WORKDIR /app
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o server ./cmd/server
FROM gcr.io/distroless/static:nonroot
USER 65532:65532
COPY --from=builder --chown=65532:65532 /app/server /server
EXPOSE 8080 8081
ENTRYPOINT ["/server"]
Redis Security Configuration
Redis AUTH Password:
# deploy/podman-compose.yml - Redis with AUTH
services:
redis:
image: redis:7-alpine
command: redis-server --requirepass ${REDIS_PASSWORD}
environment:
- REDIS_PASSWORD=${REDIS_PASSWORD}
networks:
- backend
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
Redis Client Configuration:
// internal/cache/redis.go
func NewRedisClient(cfg *config.Config) *redis.Client {
opts := &redis.Options{
Addr: fmt.Sprintf("%s:%d", cfg.RedisHost, cfg.RedisPort),
Password: cfg.RedisPassword, // AUTH password
DB: 0,
PoolSize: 20,
MinIdleConns: 5,
MaxRetries: 3,
ReadTimeout: 3 * time.Second,
WriteTimeout: 3 * time.Second,
}
// TLS for production
if cfg.RedisUseTLS {
opts.TLSConfig = &tls.Config{
MinVersion: tls.VersionTLS12,
ServerName: cfg.RedisHost,
}
}
return redis.NewClient(opts)
}
Configuration:
# .env
REDIS_HOST=redis
REDIS_PORT=6379
REDIS_PASSWORD= # Generate strong password
REDIS_USE_TLS=false # Set true in production
podman-compose Security
# deploy/podman-compose.yml
version: "3.8"
services:
redis:
image: redis:7-alpine
command: redis-server --requirepass ${REDIS_PASSWORD}
environment:
- REDIS_PASSWORD=${REDIS_PASSWORD}
networks:
- backend
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
# Redis data persistence
volumes:
- redis_data:/data
postgres:
image: postgres:16-alpine
environment:
- POSTGRES_PASSWORD=${DB_PASSWORD}
networks:
- backend
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
volumes:
- pg_data:/var/lib/postgresql/data
mcp-server:
image: guardrail-mcp:latest
read_only: true
user: "65532:65532"
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
networks:
- frontend
- backend
ports:
- "127.0.0.1:8080:8080" # MCP - localhost only
- "127.0.0.1:8081:8081" # Web UI - localhost only
environment:
- DB_SSLMODE=require
- DB_PASSWORD=${DB_PASSWORD}
- REDIS_PASSWORD=${REDIS_PASSWORD}
- MCP_API_KEY=${MCP_API_KEY}
- IDE_API_KEY=${IDE_API_KEY}
# Note: No WEB_API_KEY - Web UI is internal
tmpfs:
- /tmp:noexec,nosuid,size=100m
networks:
frontend:
internal: false
backend:
internal: true
volumes:
pg_data:
redis_data:
Note: Ports bound to 127.0.0.1 only. Use reverse proxy (nginx/traefik) for external access.
Input Validation
// internal/validation/safe_regex.go
func SafeRegex(pattern string, input string, timeout time.Duration) (bool, error) {
resultChan := make(chan bool, 1)
go func() {
re, err := regexp.Compile(pattern)
if err != nil {
resultChan <- false
return
}
resultChan <- re.MatchString(input)
}()
select {
case result := <-resultChan:
return result, nil
case <-time.After(timeout):
return false, fmt.Errorf("regex timeout - possible ReDoS")
}
}
Secrets Scanning
Prevents accidental secret exposure in documents and user input:
// internal/security/secrets_scanner.go
package security
import (
"regexp"
"strings"
)
// SecretPattern defines a detectable secret type
type SecretPattern struct {
Name string
Pattern *regexp.Regexp
Description string
}
var secretPatterns = []SecretPattern{
{
Name: "AWS Access Key ID",
Pattern: regexp.MustCompile(`AKIA[0-9A-Z]{16}`),
Description: "AWS IAM access key",
},
{
Name: "AWS Secret Key",
Pattern: regexp.MustCompile(`['"\s][0-9a-zA-Z/+]{40}['"\s]`),
Description: "Potential AWS secret key",
},
{
Name: "Private Key",
Pattern: regexp.MustCompile(`-----BEGIN (RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----`),
Description: "PEM private key",
},
{
Name: "GitHub Token",
Pattern: regexp.MustCompile(`gh[pousr]_[A-Za-z0-9_]{36,}`),
Description: "GitHub personal/token",
},
{
Name: "Slack Token",
Pattern: regexp.MustCompile(`xox[baprs]-[0-9a-zA-Z-]+`),
Description: "Slack API token",
},
{
Name: "Generic API Key",
Pattern: regexp.MustCompile(`(?i)(api[_-]?key|apikey)\s*[=:]\s*['"\s][a-z0-9_\-]{16,}['"\s]`),
Description: "Generic API key pattern",
},
{
Name: "JWT Token",
Pattern: regexp.MustCompile(`eyJ[a-zA-Z0-9_-]*\.eyJ[a-zA-Z0-9_-]*\.[a-zA-Z0-9_-]*`),
Description: "JSON Web Token",
},
}
// ScanResult represents a detected secret
type ScanResult struct {
Pattern string `json:"pattern"`
Line int `json:"line"`
Column int `json:"column"`
Match string `json:"match"`
Description string `json:"description"`
}
// ScanContent checks text for embedded secrets
func ScanContent(content string) []ScanResult {
var results []ScanResult
lines := strings.Split(content, "\n")
for lineNum, line := range lines {
for _, pattern := range secretPatterns {
matches := pattern.Pattern.FindAllStringIndex(line, -1)
for _, match := range matches {
results = append(results, ScanResult{
Pattern: pattern.Name,
Line: lineNum + 1,
Column: match[0] + 1,
Match: maskSecret(line[match[0]:match[1]]),
Description: pattern.Description,
})
}
}
}
return results
}
// maskSecret hides most of the secret for display
func maskSecret(secret string) string {
if len(secret) <= 8 {
return "****"
}
return secret[:4] + "****" + secret[len(secret)-4:]
}
// ValidateDocument checks document content before storage
func ValidateDocument(content string) error {
secrets := ScanContent(content)
if len(secrets) > 0 {
return fmt.Errorf("potential secrets detected: %d findings", len(secrets))
}
return nil
}
Usage in Web UI handlers:
// internal/web/handlers/documents.go
func UpdateDocument(c echo.Context) error {
var req UpdateDocumentRequest
if err := c.Bind(&req); err != nil {
return err
}
// Scan for secrets before saving
if findings := security.ScanContent(req.Content); len(findings) > 0 {
return c.JSON(400, map[string]interface{}{
"error": "Potential secrets detected in content",
"findings": findings,
})
}
// Continue with update...
}
Deployment
Phase 1: Build
make build-container
# Produces: guardrail-mcp:latest
Phase 2: Deploy Infrastructure
# Copy .env with real values to target server
scp .env user@target:/opt/guardrail-mcp/
scp deploy/podman-compose.yml user@target:/opt/guardrail-mcp/
# Start services
ssh user@target "cd /opt/guardrail-mcp && podman-compose up -d"
Phase 3: Run Migrations
# Apply database migrations
podman run --rm --env-file .env guardrail-mcp:latest \
/usr/local/bin/migrate -path /migrations -database "postgres://..." up
Phase 4: One-Time Ingest (Migration)
# Run ingest once to migrate MD files to database
# After this, Web UI is the source of truth
podman run --rm --env-file .env \
-v /path/to/repo:/data/repo:ro \
guardrail-mcp:latest \
/usr/local/bin/ingest --repo /data/repo
# Verify ingest
podman exec guardrail-mcp-server psql -c "SELECT COUNT(*) FROM documents;"
Configuration
Required in .env:
# Security (generate strong values)
MCP_API_KEY= # For TUI clients (Claude Code, OpenCode)
IDE_API_KEY= # For IDE extensions (VS Code, JetBrains)
JWT_SECRET= # Min 32 bytes, high entropy (openssl rand -hex 32)
# Note: Web UI requires no key (runs in same container)
# Database (use SSL in production)
DB_HOST=postgres
DB_PORT=5432
DB_NAME=guardrails
DB_USER=guardrails
DB_PASSWORD=
DB_SSLMODE=require
# Redis
REDIS_HOST=redis
REDIS_PORT=6379
REDIS_PASSWORD= # Redis AUTH password
REDIS_USE_TLS=false # Enable in production
# Server
MCP_PORT=8080
WEB_PORT=8081
LOG_LEVEL=info
REQUEST_TIMEOUT=30s
# JWT Configuration
JWT_ISSUER=guardrail-mcp
JWT_EXPIRY=15m
JWT_ROTATION_HOURS=168 # 7 days
# Rate Limiting
MCP_RATE_LIMIT=1000 # Requests per minute
IDE_RATE_LIMIT=500
SESSION_RATE_LIMIT=100
# Cache
CACHE_TTL_RULES=5m
CACHE_TTL_DOCS=10m
CACHE_TTL_SEARCH=2m
Cache Invalidation Strategy
Write-Through with TTL:
// internal/cache/cache_manager.go
package cache
import (
"context"
"fmt"
"time"
"github.com/redis/go-redis/v9"
)
// CacheManager handles cache operations with invalidation
type CacheManager struct {
client *redis.Client
ttl time.Duration
}
// Cache keys
const (
KeyActiveRules = "guardrail:rules:active"
KeyDocument = "guardrail:doc:%s" // Format with slug
KeyRule = "guardrail:rule:%s" // Format with rule_id
KeyProjectContext = "guardrail:project:%s" // Format with slug
KeySearchResults = "guardrail:search:%s" // Format with query hash
KeySession = "guardrail:session:%s" // Format with token
)
// GetActiveRules retrieves cached active rules
func (cm *CacheManager) GetActiveRules(ctx context.Context) ([]byte, error) {
return cm.client.Get(ctx, KeyActiveRules).Bytes()
}
// SetActiveRules caches active rules
func (cm *CacheManager) SetActiveRules(ctx context.Context, data []byte) error {
return cm.client.Set(ctx, KeyActiveRules, data, cm.ttl).Err()
}
// InvalidateOnRuleChange clears rule-related caches when rules are modified
func (cm *CacheManager) InvalidateOnRuleChange(ctx context.Context, ruleID string) error {
pipe := cm.client.Pipeline()
// Delete specific rule cache
pipe.Del(ctx, fmt.Sprintf(KeyRule, ruleID))
// Delete active rules list (will be rebuilt on next request)
pipe.Del(ctx, KeyActiveRules)
// Delete all search result caches (they may include this rule)
pipe.Eval(ctx, `
local keys = redis.call('keys', 'guardrail:search:*')
for _, key in ipairs(keys) do
redis.call('del', key)
end
return #keys
`, []string{})
_, err := pipe.Exec(ctx)
return err
}
// InvalidateOnDocumentChange clears doc-related caches
func (cm *CacheManager) InvalidateOnDocumentChange(ctx context.Context, slug string) error {
pipe := cm.client.Pipeline()
// Delete specific document cache
pipe.Del(ctx, fmt.Sprintf(KeyDocument, slug))
// Delete search caches that might reference this doc
pipe.Eval(ctx, `
local keys = redis.call('keys', 'guardrail:search:*')
for _, key in ipairs(keys) do
redis.call('del', key)
end
return #keys
`, []string{})
_, err := pipe.Exec(ctx)
return err
}
// InvalidateOnProjectChange clears project caches
func (cm *CacheManager) InvalidateOnProjectChange(ctx context.Context, slug string) error {
return cm.client.Del(ctx, fmt.Sprintf(KeyProjectContext, slug)).Err()
}
// WarmCache pre-populates cache after invalidation
func (cm *CacheManager) WarmCache(ctx context.Context, db *database.DB) error {
// Pre-load active rules
rules, err := db.GetActiveRules(ctx)
if err != nil {
return err
}
data, _ := json.Marshal(rules)
if err := cm.SetActiveRules(ctx, data); err != nil {
return err
}
slog.Info("Cache warmed", "rules_count", len(rules))
return nil
}
Usage in Handlers:
// After updating a rule
if err := cacheMgr.InvalidateOnRuleChange(ctx, ruleID); err != nil {
slog.Warn("Cache invalidation failed", "error", err)
}
// Trigger cache warming asynchronously
go cacheMgr.WarmCache(ctx, db)
Cache TTL Strategy:
| Data Type | TTL | Invalidation |
|---|---|---|
| Active Rules | 5 minutes | On rule change |
| Documents | 10 minutes | On doc update |
| Search Results | 2 minutes | On any content change |
| Sessions | 15 minutes | On logout/expire |
| Project Context | 30 minutes | On project update |
Horizontal Scaling
Architecture for Multiple Instances:
┌──────────────────┐
│ Load Balancer │
│ (nginx/haproxy)│
└────────┬─────────┘
│
┌─────────────────┼─────────────────┐
│ │ │
┌──────▼──────┐ ┌──────▼──────┐ ┌──────▼──────┐
│ MCP Server │ │ MCP Server │ │ MCP Server │
│ Instance 1 │ │ Instance 2 │ │ Instance N │
│ (Port 8080)│ │ (Port 8080)│ │ (Port 8080)│
└──────┬──────┘ └──────┬──────┘ └──────┬──────┘
│ │ │
└─────────────────┼─────────────────┘
│
┌─────────────────┼─────────────────┐
│ │ │
┌──────▼──────┐ ┌──────▼──────┐ ┌──────▼──────┐
│ Redis │ │ PostgreSQL │ │ Redis │
│ (Shared │ │ (Shared │ │ (Sentinel │
│ Cache) │ │ Storage) │ │ HA) │
└─────────────┘ └─────────────┘ └─────────────┘
Scaling Considerations:
-
Stateless Instances: MCP server instances are stateless
- Sessions stored in Redis (shared across instances)
- SSE connections handled by individual instances
- Sticky sessions NOT required
-
Redis Cluster/Sentinel: For HA caching
- Master-replica for read scaling
- Sentinel for automatic failover
- Partition SSE connection counters across instances
-
Database: PostgreSQL read replicas
- Write operations go to primary
- Read operations can use replicas
- Connection pool per instance
-
WebSocket/SSE Limitations:
- SSE connections are tied to specific instance
- For true horizontal scaling, use Redis Pub/Sub for cross-instance messaging
- Or use sticky sessions in load balancer (simpler)
Redis Pub/Sub for Cross-Instance Coordination:
// For cache invalidation across instances
func (cm *CacheManager) SubscribeToInvalidations(ctx context.Context) {
pubsub := cm.client.Subscribe(ctx, "cache:invalidations")
defer pubsub.Close()
ch := pubsub.Channel()
for msg := range ch {
// Handle invalidation message from other instances
var inv InvalidationMessage
json.Unmarshal([]byte(msg.Payload), &inv)
cm.handleRemoteInvalidation(ctx, inv)
}
}
func (cm *CacheManager) BroadcastInvalidation(ctx context.Context, inv InvalidationMessage) {
data, _ := json.Marshal(inv)
cm.client.Publish(ctx, "cache:invalidations", data)
}
Observability
Prometheus Metrics
// internal/metrics/prometheus.go
package metrics
import (
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
var (
ValidationsTotal = prometheus.NewCounterVec(
prometheus.CounterOpts{
Name: "guardrail_validations_total",
Help: "Total number of validations performed",
},
[]string{"tool", "result"}, // result: allowed, denied, error
)
ValidationDuration = prometheus.NewHistogramVec(
prometheus.HistogramOpts{
Name: "guardrail_validation_duration_seconds",
Help: "Validation request duration",
Buckets: []float64{0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1},
},
[]string{"tool"},
)
ActiveSessions = prometheus.NewGauge(
prometheus.GaugeOpts{
Name: "guardrail_active_sessions",
Help: "Number of active MCP sessions",
},
)
)
func init() {
prometheus.MustRegister(ValidationsTotal, ValidationDuration, ActiveSessions)
}
// Handler exposes /metrics endpoint
func Handler() http.Handler {
return promhttp.Handler()
}
Structured Logging
// All log entries include:
// - timestamp (RFC3339)
// - level (debug, info, warn, error)
// - component (mcp, web, database, cache)
// - trace_id (for request correlation)
// - key_hash (hashed API key for audit, never full key)
Audit Logging
Immutable audit trail for security events:
// internal/audit/logger.go
package audit
import (
"context"
"encoding/json"
"log/slog"
"time"
"github.com/google/uuid"
)
// EventType represents categories of audit events
type EventType string
const (
EventAuthSuccess EventType = "auth_success"
EventAuthFailure EventType = "auth_failure"
EventValidation EventType = "validation"
EventRuleChange EventType = "rule_change"
EventDocChange EventType = "document_change"
EventConfigChange EventType = "config_change"
EventAccessDenied EventType = "access_denied"
EventSessionCreated EventType = "session_created"
EventSessionExpired EventType = "session_expired"
)
// Severity represents event severity
type Severity string
const (
SevInfo Severity = "info"
SevWarning Severity = "warning"
SevCritical Severity = "critical"
)
// Event represents a single audit event
type Event struct {
ID string `json:"id"`
Timestamp time.Time `json:"timestamp"`
Type EventType `json:"type"`
Severity Severity `json:"severity"`
Actor string `json:"actor"` // Hashed API key or user ID
Action string `json:"action"` // What was done
Resource string `json:"resource"` // What was affected
Status string `json:"status"` // success, failure
Details map[string]interface{} `json:"details"` // Additional context
ClientIP string `json:"client_ip"`
UserAgent string `json:"user_agent"`
RequestID string `json:"request_id"`
}
// Logger handles audit event recording
type Logger struct {
backend chan Event
}
// NewLogger creates an audit logger
func NewLogger(bufferSize int) *Logger {
l := &Logger{
backend: make(chan Event, bufferSize),
}
go l.process()
return l
}
// Log records an audit event
func (l *Logger) Log(ctx context.Context, event Event) {
event.ID = uuid.New().String()
event.Timestamp = time.Now().UTC()
// Extract request context
if reqID := ctx.Value("request_id"); reqID != nil {
event.RequestID = reqID.(string)
}
select {
case l.backend <- event:
default:
// Buffer full - log to stderr and continue
slog.Error("audit buffer full, dropping event", "type", event.Type)
}
}
// process writes events to persistent storage
func (l *Logger) process() {
for event := range l.backend {
// Write to structured log (forward to SIEM if configured)
data, _ := json.Marshal(event)
slog.Info("AUDIT", "event", string(data))
// TODO: Write to database for long-term storage
// This enables querying audit history via Web UI
}
}
// Convenience methods
func (l *Logger) LogAuth(ctx context.Context, success bool, actor, reason string) {
eventType := EventAuthSuccess
severity := SevInfo
if !success {
eventType = EventAuthFailure
severity = SevWarning
}
l.Log(ctx, Event{
Type: eventType,
Severity: severity,
Actor: actor,
Action: "authenticate",
Status: map[bool]string{true: "success", false: "failure"}[success],
Details: map[string]interface{}{"reason": reason},
})
}
func (l *Logger) LogValidation(ctx context.Context, actor, tool string, allowed bool, violations int) {
l.Log(ctx, Event{
Type: EventValidation,
Severity: SevInfo,
Actor: actor,
Action: "validate",
Resource: tool,
Status: map[bool]string{true: "allowed", false: "denied"}[allowed],
Details: map[string]interface{}{
"violations": violations,
},
})
}
func (l *Logger) LogRuleChange(ctx context.Context, actor, ruleID, action string) {
l.Log(ctx, Event{
Type: EventRuleChange,
Severity: SevCritical, // Rule changes are security-critical
Actor: actor,
Action: action, // create, update, delete, toggle
Resource: ruleID,
Status: "success",
})
}
Database Schema for Audit Events:
CREATE TABLE audit_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
event_id VARCHAR(50) NOT NULL,
timestamp TIMESTAMP NOT NULL DEFAULT NOW(),
event_type VARCHAR(50) NOT NULL,
severity VARCHAR(20) NOT NULL,
actor VARCHAR(64) NOT NULL, -- Hashed identifier
action VARCHAR(50) NOT NULL,
resource VARCHAR(255),
status VARCHAR(20) NOT NULL,
details JSONB DEFAULT '{}',
client_ip INET,
request_id VARCHAR(50),
created_at TIMESTAMP NOT NULL DEFAULT NOW()
) PARTITION BY RANGE (timestamp);
-- Indexes for efficient querying
CREATE INDEX idx_audit_time ON audit_log(timestamp DESC);
CREATE INDEX idx_audit_actor ON audit_log(actor);
CREATE INDEX idx_audit_type ON audit_log(event_type);
CREATE INDEX idx_audit_resource ON audit_log(resource);
-- Retention: Partition by month, drop old partitions after 1 year
Audit Event Retention:
- Hot storage (PostgreSQL): 30 days for Web UI queries
- Cold storage (JSONL files): 1 year for compliance
- Archive to S3/Glacier after 1 year if needed
Security Checklist
Authentication & Authorization
- JWT implementation with 15-minute expiration (MCP)
- JWT secret validation (32+ bytes, entropy check)
- API key authentication for external endpoints (MCP, IDE)
- Web UI internal communication (no key needed)
- API key masking in logs (hash only)
- Separate MCP and IDE API keys
- Key rotation mechanism
Network Security
- DB_SSLMODE=require enforced
- Internal network for PostgreSQL/Redis
- No external exposure of backend services
- Redis AUTH password configured
- Redis TLS enabled for production
- Web UI bound to localhost only
Input Validation
- Regex timeout protection (ReDoS prevention)
- UUID validation for document IDs
- Pattern validation for rule regex
- SQL injection protection (parameterized queries)
- Search query sanitization
Secrets Protection
- Secrets scanning in documents
- Pre-commit secret detection
- Masking of detected secrets in logs
Observability
- Prometheus metrics endpoint (/metrics)
- RED metrics (Rate, Errors, Duration)
- Active session gauge
- Structured logging with trace_id
- Audit logging infrastructure
- Security event logging
Operational Readiness
- Liveness endpoint (/health/live)
- Readiness endpoint (/health/ready)
- Circuit breakers for DB and Redis
- Database connection pooling (50+ max)
- HTTP timeouts (5s read, 10s write)
- Graceful shutdown (30s timeout)
- Redis session TTL (15 min)
- SSE connection limits (100 per instance)
Web Security
- CSRF protection on all state-changing endpoints
- SameSite=Strict cookies
- Content Security Policy headers
- XSS protection via output encoding
- Security headers (X-Content-Type-Options, X-Frame-Options, etc.)
Rate Limiting
- Per-API-key rate limits (MCP: 1000/min, IDE: 500/min)
- Distributed rate limiting with Redis
- Per-session rate limiting
- Rate limit headers in responses
Container Security
- Distroless/minimal base image
- Non-root user (UID 65532)
- Read-only filesystem
- No new privileges flag
- Capability dropping
- tmpfs for /tmp with noexec
Audit & Logging
- Structured audit logging
- Audit event database schema
- Security event logging (auth failures, etc.)
- PII scrubbing in logs
- Log retention policy (30 days hot, 1 year cold)
Cache Management
- Cache invalidation on write
- Cache warming after invalidation
- TTL strategy per data type
- Cross-instance cache coordination (Pub/Sub)
Files to Create
| File | Lines | Purpose |
|---|---|---|
go.mod |
35 | Go module with dependencies |
cmd/server/main.go |
100 | Server entry point |
cmd/ingest/main.go |
80 | Ingest tool |
internal/config/config.go |
60 | Configuration struct |
internal/models/*.go |
150 | Data models |
internal/database/*.go |
400 | Database layer + migrations |
internal/cache/redis.go |
100 | Redis client |
internal/ingester/*.go |
280 | Ingestion logic |
internal/guardrails/*.go |
300 | Validation + enforcement |
internal/web/*.go |
400 | HTTP handlers + middleware |
internal/mcp/*.go |
350 | MCP handlers |
deploy/Dockerfile |
25 | Server container |
deploy/Dockerfile.ingest |
20 | Ingest job container |
deploy/podman-compose.yml |
60 | Orchestration |
.env.example |
50 | Config template |
Makefile |
80 | Build automation |
README.md |
250 | Setup guide |
Performance Targets
| Metric | Target |
|---|---|
| Validation p99 latency | < 50ms (cached) |
| Validation p99 latency | < 200ms (uncached) |
| Database query time | < 10ms |
| Cache hit ratio | > 90% |
| Max concurrent sessions | 1000 |
| Ingest throughput | 100 docs/min |
Last Updated: 2026-02-07 (v1.2 - Security & Scalability Review Complete)