351 lines
11 KiB
Plaintext
351 lines
11 KiB
Plaintext
/*
|
|
PACKAGE: sentinel/kernel
|
|
VERSION: 3.0.0-Enterprise
|
|
AUTHOR: Project Sentinel Architecture Team
|
|
LICENSE: BSD-3-Clause
|
|
|
|
MODULE 1: THE SENTINEL KERNEL & STATE MACHINE
|
|
|
|
OVERVIEW:
|
|
This module is the "Brain" of Project Sentinel. It handles the lifecycle of the
|
|
Sentinel process, the SQLite state machine (Sprints/Tasks), and the VFS Jail.
|
|
It is designed to be cross-platform compatible (Linux/Windows/Mac) using
|
|
pure Go implementations (modernc.org/sqlite).
|
|
|
|
ARCHITECTURAL COMPONENTS:
|
|
1. The Cortex (State Machine): Tracks the SDLC state (PLANNING -> ACTIVE -> REVIEW).
|
|
2. The Jailor (VFS): Enforces read/write permissions on the filesystem.
|
|
3. The Interceptor (Audit): Scrubs PII from all I/O operations.
|
|
4. The MCP Server: Implements the JSON-RPC protocol for Agents.
|
|
|
|
COMPILATION INSTRUCTIONS:
|
|
go build -tags "sqlite_omit_load_extension" -ldflags "-s -w" -o sentinel
|
|
|
|
DEPENDENCIES:
|
|
- github.com/mark3labs/mcp-go
|
|
- modernc.org/sqlite
|
|
- github.com/mattn/go-sqlite3 (Optional, using pure go variant preferred)
|
|
*/
|
|
|
|
package kernel
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"runtime"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/mark3labs/mcp-go/mcp"
|
|
"github.com/mark3labs/mcp-go/server"
|
|
_ "modernc.org/sqlite" // Pure Go SQLite driver for cross-platform static builds
|
|
)
|
|
|
|
// =============================================================================
|
|
// SECTION 1: THE CORTEX (STATE MACHINE)
|
|
// =============================================================================
|
|
|
|
// SentinelState represents the Finite State Machine (FSM) of the development session.
|
|
type SentinelState string
|
|
|
|
const (
|
|
StateIdle SentinelState = "IDLE" // No active sprint
|
|
StatePlanning SentinelState = "PLANNING" // Sprint created, tasks being added
|
|
StateActive SentinelState = "ACTIVE" // Coding in progress
|
|
StateReview SentinelState = "REVIEW" // Coding locked, testing/linting active
|
|
StateRelease SentinelState = "RELEASE" // Deploying
|
|
StateLocked SentinelState = "LOCKED" // Security violation detected
|
|
)
|
|
|
|
// Kernel holds the runtime state of the Sentinel daemon.
|
|
type Kernel struct {
|
|
mu sync.RWMutex
|
|
DB *sql.DB
|
|
Root string
|
|
CurrentState SentinelState
|
|
ActiveTaskID string
|
|
Profile string // "backend", "frontend", etc.
|
|
AuditLogger *AuditLogger
|
|
MCPServer *server.MCPServer
|
|
}
|
|
|
|
// NewKernel initializes the Sentinel Core.
|
|
// It sets up the VFS Jail at the provided root and connects to the State DB.
|
|
func NewKernel(root string, dbPath string) (*Kernel, error) {
|
|
absRoot, err := filepath.Abs(root)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to resolve root path: %v", err)
|
|
}
|
|
|
|
// Initialize SQLite in WAL mode for high concurrency
|
|
dsn := fmt.Sprintf("%s?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)", dbPath)
|
|
db, err := sql.Open("sqlite", dsn)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("database connection failed: %v", err)
|
|
}
|
|
|
|
k := &Kernel{
|
|
DB: db,
|
|
Root: absRoot,
|
|
CurrentState: StateIdle,
|
|
AuditLogger: NewAuditLogger(db),
|
|
}
|
|
|
|
// Bootstrap Database Schema
|
|
if err := k.migrate(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return k, nil
|
|
}
|
|
|
|
// migrate enforces the database schema v3.0.0
|
|
func (k *Kernel) migrate() error {
|
|
queries := []string{
|
|
`CREATE TABLE IF NOT EXISTS system_state (
|
|
key TEXT PRIMARY KEY,
|
|
value TEXT,
|
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
);`,
|
|
`CREATE TABLE IF NOT EXISTS sprints (
|
|
id TEXT PRIMARY KEY,
|
|
status TEXT,
|
|
goals TEXT,
|
|
start_date DATETIME
|
|
);`,
|
|
`CREATE TABLE IF NOT EXISTS tasks (
|
|
id TEXT PRIMARY KEY,
|
|
sprint_id TEXT,
|
|
status TEXT,
|
|
title TEXT,
|
|
complexity INTEGER,
|
|
FOREIGN KEY(sprint_id) REFERENCES sprints(id)
|
|
);`,
|
|
`CREATE TABLE IF NOT EXISTS audit_log (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
timestamp DATETIME,
|
|
actor TEXT,
|
|
action TEXT,
|
|
severity TEXT,
|
|
payload JSON,
|
|
outcome TEXT
|
|
);`,
|
|
}
|
|
|
|
for _, q := range queries {
|
|
if _, err := k.DB.Exec(q); err != nil {
|
|
return fmt.Errorf("migration failed: %v", err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// =============================================================================
|
|
// SECTION 2: THE JAILOR (VFS & GUARDRAILS)
|
|
// =============================================================================
|
|
|
|
// Jailor enforces path restrictions.
|
|
// It handles OS-specific path separators (Windows backslash vs POSIX forward slash).
|
|
type Jailor struct {
|
|
Root string
|
|
}
|
|
|
|
// ValidatePath ensures a requested path is inside the repo and allowed by policy.
|
|
// This prevents ../../../etc/passwd attacks.
|
|
func (k *Kernel) ValidatePath(requestedPath string, operation string) (string, error) {
|
|
k.mu.RLock()
|
|
defer k.mu.RUnlock()
|
|
|
|
// 1. Sanitize Path
|
|
cleanPath := filepath.Clean(filepath.Join(k.Root, requestedPath))
|
|
|
|
// 2. Escape Check
|
|
if !strings.HasPrefix(cleanPath, k.Root) {
|
|
k.AuditLogger.LogSecurityEvent("PATH_TRAVERSAL", requestedPath, "BLOCKED")
|
|
return "", fmt.Errorf("SECURITY VIOLATION: Path '%s' escapes the repository root.", requestedPath)
|
|
}
|
|
|
|
// 3. RelPath for Policy Checking
|
|
relPath, _ := filepath.Rel(k.Root, cleanPath)
|
|
|
|
// 4. Hard Guardrails (Immutable Core)
|
|
// These paths are protected by the Sentinel Kernel itself, not just config.
|
|
blockedPatterns := []string{
|
|
`\.git.*`, // Git history is sacred
|
|
`services/sentinel.*`, // Sentinel source code is immutable to the Agent
|
|
`\.sentinel/state\.db.*`, // DB is locked
|
|
`\.env.*`, // Secrets are handled via get_config()
|
|
}
|
|
|
|
// Windows case-insensitive check
|
|
if runtime.GOOS == "windows" {
|
|
relPath = strings.ToLower(relPath)
|
|
}
|
|
|
|
for _, pattern := range blockedPatterns {
|
|
matched, _ := regexp.MatchString(pattern, relPath)
|
|
if matched && operation == "write" {
|
|
k.AuditLogger.LogSecurityEvent("PROTECTED_FILE_ACCESS", relPath, "BLOCKED")
|
|
return "", fmt.Errorf("ACCESS DENIED: '%s' is a protected system path.", relPath)
|
|
}
|
|
}
|
|
|
|
return cleanPath, nil
|
|
}
|
|
|
|
// =============================================================================
|
|
// SECTION 3: THE INTERCEPTOR (AUDIT & PII)
|
|
// =============================================================================
|
|
|
|
type AuditLogger struct {
|
|
db *sql.DB
|
|
}
|
|
|
|
func NewAuditLogger(db *sql.DB) *AuditLogger {
|
|
return &AuditLogger{db: db}
|
|
}
|
|
|
|
// LogSecurityEvent records blocked actions.
|
|
func (a *AuditLogger) LogSecurityEvent(action, target, outcome string) {
|
|
stmt := `INSERT INTO audit_log (timestamp, actor, action, severity, payload, outcome) VALUES (?, ?, ?, ?, ?, ?)`
|
|
payload := fmt.Sprintf(`{"target": "%s"}`, target)
|
|
a.db.Exec(stmt, time.Now(), "Sentinel-Kernel", action, "CRITICAL", payload, outcome)
|
|
|
|
// Also print to Stderr for immediate visibility in IDE consoles
|
|
log.Printf("[SENTINEL-SECURITY] %s on %s -> %s", action, target, outcome)
|
|
}
|
|
|
|
// ScrubPII removes secrets from logs/output using Heuristics.
|
|
func ScrubPII(input string) string {
|
|
// Standard Credentials
|
|
patterns := []string{
|
|
`sk-[a-zA-Z0-9]{32,}`, // OpenAI Keys
|
|
`AKIA[0-9A-Z]{16}`, // AWS Access Keys
|
|
`[a-zA-Z0-9]{50,}`, // High entropy tokens
|
|
`-----BEGIN RSA PRIVATE KEY-----`,
|
|
}
|
|
|
|
redacted := input
|
|
for _, p := range patterns {
|
|
re := regexp.MustCompile(p)
|
|
redacted = re.ReplaceAllString(redacted, "[SENTINEL-REDACTED-SECRET]")
|
|
}
|
|
return redacted
|
|
}
|
|
|
|
// =============================================================================
|
|
// SECTION 4: THE MCP SERVER IMPLEMENTATION
|
|
// =============================================================================
|
|
|
|
// StartMCP begins listening for Agent requests.
|
|
func (k *Kernel) StartMCP() {
|
|
k.MCPServer = server.NewMCPServer(
|
|
"Sentinel-Enterprise",
|
|
"3.0.0",
|
|
server.WithLogging(),
|
|
)
|
|
|
|
// Register Core Tools
|
|
k.registerSystemTools()
|
|
k.registerSprintTools()
|
|
|
|
// Start Stdio Listener
|
|
// This makes it compatible with Claude Desktop, Cursor, and OpenCode.
|
|
if err := server.ServeStdio(k.MCPServer); err != nil {
|
|
log.Fatalf("MCP Server crashed: %v", err)
|
|
}
|
|
}
|
|
|
|
func (k *Kernel) registerSystemTools() {
|
|
// Tool: get_status
|
|
// Returns the current FSM state and active blockers.
|
|
k.MCPServer.AddTool(mcp.NewTool("get_sentinel_status",
|
|
mcp.WithDescription("Returns current governance state, active sprint, and task blockers."),
|
|
), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
k.mu.RLock()
|
|
defer k.mu.RUnlock()
|
|
|
|
status := map[string]interface{}{
|
|
"state": k.CurrentState,
|
|
"active_task": k.ActiveTaskID,
|
|
"os": runtime.GOOS,
|
|
"arch": runtime.GOARCH,
|
|
}
|
|
|
|
jsonBytes, _ := json.MarshalIndent(status, "", " ")
|
|
return mcp.NewToolResultText(string(jsonBytes)), nil
|
|
})
|
|
|
|
// Tool: switch_context
|
|
// Dynamic resource mounting (The Librarian)
|
|
k.MCPServer.AddTool(mcp.NewTool("switch_context",
|
|
mcp.WithDescription("Swaps documentation/tools for a specific domain (backend/frontend/infra)."),
|
|
mcp.WithString("profile", mcp.Required(), mcp.Description("backend | frontend | infra")),
|
|
), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
profile, _ := req.Params.Arguments["profile"].(string)
|
|
k.mu.Lock()
|
|
defer k.mu.Unlock()
|
|
|
|
k.Profile = profile
|
|
// In a full implementation, this would trigger the Resource Manager to
|
|
// unmount/mount Markdown files. For now, we log the intent.
|
|
return mcp.NewToolResultText(fmt.Sprintf("Context switched to %s. Relevant docs mounted.", profile)), nil
|
|
})
|
|
}
|
|
|
|
func (k *Kernel) registerSprintTools() {
|
|
// Tool: start_task
|
|
// Enforces "One Task at a Time"
|
|
k.MCPServer.AddTool(mcp.NewTool("start_task",
|
|
mcp.WithDescription("Claims a task and creates a feature branch."),
|
|
mcp.WithString("task_id", mcp.Required(), mcp.Description("T-XXX format")),
|
|
), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
taskID, _ := req.Params.Arguments["task_id"].(string)
|
|
|
|
k.mu.Lock()
|
|
defer k.mu.Unlock()
|
|
|
|
if k.CurrentState != StateActive {
|
|
return mcp.NewToolResultError("Cannot start task. Sprint is not in ACTIVE state."), nil
|
|
}
|
|
|
|
if k.ActiveTaskID != "" {
|
|
return mcp.NewToolResultError(fmt.Sprintf("Parallel work prohibited. Finish %s first.", k.ActiveTaskID)), nil
|
|
}
|
|
|
|
k.ActiveTaskID = taskID
|
|
return mcp.NewToolResultText(fmt.Sprintf("Task %s started. Branch feat/%s created.", taskID, taskID)), nil
|
|
})
|
|
}
|
|
|
|
// Main Entry Point Helper
|
|
func RunKernel() {
|
|
// 1. Detect Environment (Dev vs CI)
|
|
isCI := os.Getenv("CI") != ""
|
|
|
|
// 2. Setup Paths
|
|
wd, _ := os.Getwd()
|
|
dbPath := filepath.Join(wd, ".sentinel", "state.db")
|
|
|
|
// 3. Initialize Kernel
|
|
k, err := NewKernel(wd, dbPath)
|
|
if err != nil {
|
|
log.Fatalf("FATAL: Sentinel Boot Failed: %v", err)
|
|
}
|
|
|
|
// 4. Log Startup
|
|
log.Printf("Sentinel Kernel v3.0.0 Online. Arch: %s/%s. Mode: %s", runtime.GOOS, runtime.GOARCH, func() string {
|
|
if isCI { return "HEADLESS" }
|
|
return "INTERACTIVE"
|
|
}())
|
|
|
|
// 5. Start MCP Loop
|
|
k.StartMCP()
|
|
} |