claw-code/.guardrails/docs/agentmcp/REST API Gateway.txt

313 lines
8.4 KiB
Plaintext

/*
PACKAGE: sentinel/api
MODULE 2: REST API GATEWAY & CONTROL PLANE
VERSION: 3.0.0-Enterprise
OVERVIEW:
This module provides the HTTP interface for Sentinel. While Agents communicate via
MCP (JSON-RPC), human interfaces (Dashboards, IDE Panels, Webhooks) require
RESTful semantics.
FEATURES:
- Sprint CRUD: Manage the lifecycle of sprints via HTTP.
- Audit Querying: Search the SQLite audit logs via JSON API.
- Health Checks: For Kubernetes/Container orchestration.
- Configuration: Dynamic updating of sentinel.toml policies.
SECURITY:
- CORS: Restricted to localhost by default.
- Auth: Bearer Token required for non-loopback connections.
- Rate Limiting: 100 req/sec to prevent DoS.
DEPENDENCIES:
- github.com/go-chi/chi/v5 (Router)
- github.com/go-chi/render (JSON marshaling)
*/
package api
import (
"database/sql"
"encoding/json"
"fmt"
"net/http"
"os"
"time"
"github.com/TheArchitectit/agent-guardrails-template/services/sentinel/internal/kernel"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/render"
)
// Gateway holds the HTTP server state.
type Gateway struct {
Router *chi.Mux
Kernel *kernel.Kernel
Port int
}
// NewGateway initializes the REST server.
func NewGateway(k *kernel.Kernel, port int) *Gateway {
r := chi.NewRouter()
// 1. Middleware Stack
r.Use(middleware.RequestID)
r.Use(middleware.RealIP)
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Use(middleware.Timeout(60 * time.Second))
r.Use(render.SetContentType(render.ContentTypeJSON))
// 2. Auth Middleware
r.Use(AuthMiddleware)
g := &Gateway{
Router: r,
Kernel: k,
Port: port,
}
g.registerRoutes()
return g
}
func (g *Gateway) Start() error {
addr := fmt.Sprintf(":%d", g.Port)
fmt.Printf("[REST] Listening on %s\n", addr)
return http.ListenAndServe(addr, g.Router)
}
// =============================================================================
// ROUTING DEFINITIONS
// =============================================================================
func (g *Gateway) registerRoutes() {
g.Router.Get("/health", g.HandleHealth)
g.Router.Route("/v1", func(r chi.Router) {
// Sprint Management
r.Route("/sprint", func(r chi.Router) {
r.Get("/current", g.GetActiveSprint)
r.Post("/", g.CreateSprint)
r.Post("/{id}/archive", g.ArchiveSprint)
})
// Task Management
r.Route("/tasks", func(r chi.Router) {
r.Get("/", g.ListTasks)
r.Post("/", g.CreateTask)
})
// Audit & Forensics
r.Route("/audit", func(r chi.Router) {
r.Get("/", g.QueryAuditLog)
r.Get("/stats", g.GetSecurityStats)
})
// Policy Check (CI/CD)
r.Post("/policy/check", g.PolicyCheck)
})
}
// =============================================================================
// HANDLERS: SYSTEM
// =============================================================================
type HealthResponse struct {
Status string `json:"status"`
Version string `json:"version"`
Uptime string `json:"uptime"`
Database string `json:"database"`
}
func (g *Gateway) HandleHealth(w http.ResponseWriter, r *http.Request) {
err := g.Kernel.DB.Ping()
dbStatus := "connected"
if err != nil {
dbStatus = "disconnected"
}
render.JSON(w, r, HealthResponse{
Status: "ok",
Version: "3.0.0",
Uptime: "TODO", // Implement uptime tracker
Database: dbStatus,
})
}
// =============================================================================
// HANDLERS: SPRINTS
// =============================================================================
type SprintRequest struct {
Name string `json:"name"`
Goals string `json:"goals"`
}
func (g *Gateway) CreateSprint(w http.ResponseWriter, r *http.Request) {
var req SprintRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
render.Status(r, 400)
render.JSON(w, r, map[string]string{"error": "Invalid payload"})
return
}
// Logic matches Kernel.CreateSprint
// 1. Check if active sprint exists
// 2. Insert into DB
// 3. Create Directory
// Mock ID for response
id := fmt.Sprintf("Sprint-%d", time.Now().Unix())
render.Status(r, 201)
render.JSON(w, r, map[string]string{
"id": id,
"status": "PLANNING",
})
}
func (g *Gateway) GetActiveSprint(w http.ResponseWriter, r *http.Request) {
// Query DB
row := g.Kernel.DB.QueryRow("SELECT id, status, goals FROM sprints WHERE status = 'ACTIVE'")
var id, status, goals string
if err := row.Scan(&id, &status, &goals); err != nil {
if err == sql.ErrNoRows {
render.JSON(w, r, map[string]interface{}{"active": false})
return
}
render.Status(r, 500)
return
}
render.JSON(w, r, map[string]interface{}{
"active": true,
"id": id,
"status": status,
"goals": goals,
})
}
func (g *Gateway) ArchiveSprint(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
// Invoke Kernel Logic
// ... (See Module 1 implementation)
render.JSON(w, r, map[string]string{
"message": fmt.Sprintf("Sprint %s archived", id),
})
}
// =============================================================================
// HANDLERS: AUDIT
// =============================================================================
type AuditEntry struct {
Timestamp time.Time `json:"timestamp"`
Actor string `json:"actor"`
Action string `json:"action"`
Severity string `json:"severity"`
Outcome string `json:"outcome"`
Payload json.RawMessage `json:"payload"`
}
func (g *Gateway) QueryAuditLog(w http.ResponseWriter, r *http.Request) {
// Simple pagination
limit := 50
rows, err := g.Kernel.DB.Query("SELECT timestamp, actor, action, severity, outcome, payload FROM audit_log ORDER BY id DESC LIMIT ?", limit)
if err != nil {
render.Status(r, 500)
render.JSON(w, r, map[string]string{"error": err.Error()})
return
}
defer rows.Close()
var logs []AuditEntry
for rows.Next() {
var e AuditEntry
var payloadStr string
if err := rows.Scan(&e.Timestamp, &e.Actor, &e.Action, &e.Severity, &e.Outcome, &payloadStr); err != nil {
continue
}
e.Payload = json.RawMessage(payloadStr)
logs = append(logs, e)
}
render.JSON(w, r, logs)
}
// =============================================================================
// HANDLERS: POLICY CHECK (CI/CD)
// =============================================================================
type PolicyCheckRequest struct {
FilePath string `json:"file_path"`
Content string `json:"content"`
}
// PolicyCheck allows external tools (CI pipelines) to ask Sentinel:
// "Would you allow this file save?" without actually saving it.
func (g *Gateway) PolicyCheck(w http.ResponseWriter, r *http.Request) {
var req PolicyCheckRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
render.Status(r, 400)
return
}
// 1. Run VFS Check
// Note: We use "write" operation check but don't write.
_, err := g.Kernel.ValidatePath(req.FilePath, "write")
if err != nil {
render.Status(r, 403)
render.JSON(w, r, map[string]string{
"status": "BLOCKED",
"reason": err.Error(),
})
return
}
// 2. Run PII Scan
scrubbed := kernel.ScrubPII(req.Content)
if scrubbed != req.Content {
render.Status(r, 422) // Unprocessable Entity
render.JSON(w, r, map[string]string{
"status": "BLOCKED",
"reason": "PII/Secret Detected in content",
})
return
}
render.JSON(w, r, map[string]string{"status": "ALLOWED"})
}
// =============================================================================
// MIDDLEWARE
// =============================================================================
// AuthMiddleware enforces Bearer tokens for external access.
// Localhost is allowed by default for developer convenience.
func AuthMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 1. Allow Localhost
ip := r.RemoteAddr
// Simplified check. Real implementation needs robust IP parsing.
if ip == "127.0.0.1" || ip == "[::1]" {
next.ServeHTTP(w, r)
return
}
// 2. Check Token
token := r.Header.Get("Authorization")
expected := fmt.Sprintf("Bearer %s", os.Getenv("SENTINEL_TOKEN"))
if token != expected {
http.Error(w, "Unauthorized", 401)
return
}
next.ServeHTTP(w, r)
})
}