-
Notifications
You must be signed in to change notification settings - Fork 0
feat: sentinel-mcp — MCP sidecar for governance telemetry #10
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,72 @@ | ||
| name: Release | ||
|
|
||
| on: | ||
| release: | ||
| types: [published] | ||
| workflow_dispatch: | ||
| inputs: | ||
| tag: | ||
| description: "Release tag (e.g., v0.1.0)" | ||
| required: true | ||
|
|
||
| permissions: | ||
| contents: write | ||
|
|
||
| jobs: | ||
| build: | ||
| strategy: | ||
| matrix: | ||
| include: | ||
| - os: linux | ||
| arch: amd64 | ||
| runner: ubuntu-latest | ||
| - os: linux | ||
| arch: arm64 | ||
| runner: ubuntu-latest | ||
| - os: darwin | ||
| arch: amd64 | ||
| runner: macos-latest | ||
| - os: darwin | ||
| arch: arm64 | ||
| runner: macos-latest | ||
|
|
||
| runs-on: ${{ matrix.runner }} | ||
|
|
||
| env: | ||
| RELEASE_TAG: ${{ github.event.release.tag_name || github.event.inputs.tag }} | ||
| BUILD_OS: ${{ matrix.os }} | ||
| BUILD_ARCH: ${{ matrix.arch }} | ||
|
|
||
| steps: | ||
| - uses: actions/checkout@v4 | ||
|
|
||
| - uses: actions/setup-go@v5 | ||
| with: | ||
| go-version: "1.22" | ||
|
|
||
| - name: Build sentinel CLI | ||
| env: | ||
| GOOS: ${{ matrix.os }} | ||
| GOARCH: ${{ matrix.arch }} | ||
| CGO_ENABLED: "0" | ||
| run: | | ||
| go build -ldflags="-s -w" \ | ||
| -o "sentinel-${BUILD_OS}-${BUILD_ARCH}" \ | ||
| ./cmd/sentinel/ | ||
|
|
||
| - name: Build sentinel-mcp | ||
| env: | ||
| GOOS: ${{ matrix.os }} | ||
| GOARCH: ${{ matrix.arch }} | ||
| CGO_ENABLED: "0" | ||
| run: | | ||
| go build -ldflags="-s -w" \ | ||
| -o "sentinel-mcp-${BUILD_OS}-${BUILD_ARCH}" \ | ||
| ./cmd/sentinel-mcp/ | ||
|
|
||
| - name: Upload release assets | ||
| uses: softprops/action-gh-release@v2 | ||
| with: | ||
| files: | | ||
| sentinel-* | ||
| tag_name: ${{ github.event.release.tag_name || github.event.inputs.tag }} |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,51 @@ | ||
| package main | ||
|
|
||
| import ( | ||
| "context" | ||
| "fmt" | ||
| "os" | ||
|
|
||
| "github.com/jackc/pgx/v5/pgxpool" | ||
|
|
||
| "github.com/AgentGuardHQ/sentinel/internal/mcp" | ||
| ) | ||
|
|
||
| func main() { | ||
| // Database URL — from env or passed by the MCP client config | ||
| dbURL := os.Getenv("NEON_DATABASE_URL") | ||
| if dbURL == "" { | ||
| fmt.Fprintln(os.Stderr, "sentinel-mcp: NEON_DATABASE_URL is required") | ||
| os.Exit(1) | ||
| } | ||
|
|
||
| // Tenant ID — identifies whose events these are | ||
| tenantID := os.Getenv("SENTINEL_TENANT_ID") | ||
| if tenantID == "" { | ||
| tenantID = "00000000-0000-0000-0000-000000000000" // default single-tenant | ||
| } | ||
|
|
||
| // Connect to Neon | ||
| pool, err := pgxpool.New(context.Background(), dbURL) | ||
| if err != nil { | ||
| fmt.Fprintf(os.Stderr, "sentinel-mcp: database connection failed: %v\n", err) | ||
| os.Exit(1) | ||
| } | ||
| defer pool.Close() | ||
|
|
||
| if err := pool.Ping(context.Background()); err != nil { | ||
| fmt.Fprintf(os.Stderr, "sentinel-mcp: database ping failed: %v\n", err) | ||
| os.Exit(1) | ||
| } | ||
|
|
||
| // Build MCP server | ||
| server := mcp.New() | ||
| mcp.RegisterTools(server, pool, tenantID) | ||
|
|
||
| fmt.Fprintln(os.Stderr, "sentinel-mcp: ready (stdio)") | ||
|
|
||
| // Run the stdio JSON-RPC loop | ||
| if err := server.Run(); err != nil { | ||
| fmt.Fprintf(os.Stderr, "sentinel-mcp: %v\n", err) | ||
| os.Exit(1) | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,6 @@ | ||
| module github.com/AgentGuardHQ/sentinel | ||
|
|
||
| go 1.24.0 | ||
| go 1.21 | ||
|
|
||
| require ( | ||
| github.com/jackc/pgx/v5 v5.7.4 | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,101 @@ | ||
| package mcp | ||
|
|
||
| import ( | ||
| "bufio" | ||
| "context" | ||
| "encoding/json" | ||
| "fmt" | ||
| "os" | ||
| "strings" | ||
|
|
||
| "github.com/jackc/pgx/v5/pgxpool" | ||
| ) | ||
|
|
||
| // ChitinEvent matches the JSONL format emitted by chitin's hook/emit.go. | ||
| type ChitinEvent struct { | ||
| Timestamp string `json:"ts"` | ||
| SessionID string `json:"sid"` | ||
| Agent string `json:"agent"` | ||
| Tool string `json:"tool"` | ||
| Action string `json:"action"` | ||
| Path string `json:"path,omitempty"` | ||
| Command string `json:"command,omitempty"` | ||
| Outcome string `json:"outcome"` | ||
| Reason string `json:"reason,omitempty"` | ||
| Source string `json:"source,omitempty"` | ||
| LatencyUs int64 `json:"latency_us"` | ||
| } | ||
|
|
||
| // IngestFile reads a JSONL file and inserts events into governance_events. | ||
| // Returns the number of events ingested. | ||
| func IngestFile(pool *pgxpool.Pool, path string, tenantID string) (int, error) { | ||
| f, err := os.Open(path) | ||
| if err != nil { | ||
| return 0, fmt.Errorf("open events file: %w", err) | ||
| } | ||
| defer f.Close() | ||
|
|
||
| ctx := context.Background() | ||
| count := 0 | ||
| scanner := bufio.NewScanner(f) | ||
|
|
||
| for scanner.Scan() { | ||
| line := strings.TrimSpace(scanner.Text()) | ||
| if line == "" { | ||
| continue | ||
| } | ||
|
|
||
| var ev ChitinEvent | ||
| if err := json.Unmarshal([]byte(line), &ev); err != nil { | ||
| continue // skip malformed lines | ||
| } | ||
|
Comment on lines
+48
to
+51
|
||
|
|
||
| metadata, _ := json.Marshal(map[string]any{ | ||
| "command": ev.Command, | ||
| "source": ev.Source, | ||
| "latency_us": ev.LatencyUs, | ||
| "reason": ev.Reason, | ||
| }) | ||
|
|
||
| riskLevel := "low" | ||
| if ev.Outcome == "deny" { | ||
| riskLevel = "medium" | ||
| if ev.Source == "invariant" { | ||
| riskLevel = "high" | ||
| } | ||
| } | ||
|
|
||
| _, err := pool.Exec(ctx, ` | ||
| INSERT INTO governance_events | ||
| (tenant_id, session_id, agent_id, event_type, action, resource, outcome, risk_level, event_source, driver_type, metadata, timestamp) | ||
| VALUES | ||
| ($1, $2, $3, 'tool_call', $4, $5, $6, $7, 'agent', $8, $9::jsonb, $10::timestamptz) | ||
| `, | ||
| tenantID, | ||
|
Comment on lines
+68
to
+74
|
||
| ev.SessionID, | ||
| ev.Agent, | ||
| ev.Tool, | ||
| coalesce(ev.Path, ev.Command), | ||
| ev.Outcome, | ||
| riskLevel, | ||
| ev.Agent, | ||
| string(metadata), | ||
| ev.Timestamp, | ||
| ) | ||
|
Comment on lines
+68
to
+84
|
||
| if err != nil { | ||
| return count, fmt.Errorf("insert event: %w", err) | ||
| } | ||
| count++ | ||
| } | ||
|
|
||
| return count, scanner.Err() | ||
| } | ||
|
|
||
| func coalesce(values ...string) string { | ||
| for _, v := range values { | ||
| if v != "" { | ||
| return v | ||
| } | ||
| } | ||
| return "" | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
bufio.Scanneruses a ~64K token limit by default; a single large JSONL line will cause scanning to stop withErrTooLongand ingestion will fail. Set a larger scanner buffer (or usebufio.Reader) so ingestion is robust to larger events.