Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions .github/workflows/release.yml
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 }}
51 changes: 51 additions & 0 deletions cmd/sentinel-mcp/main.go
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)
}
}
2 changes: 1 addition & 1 deletion go.mod
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
Expand Down
101 changes: 101 additions & 0 deletions internal/mcp/ingest.go
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)
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bufio.Scanner uses a ~64K token limit by default; a single large JSONL line will cause scanning to stop with ErrTooLong and ingestion will fail. Set a larger scanner buffer (or use bufio.Reader) so ingestion is robust to larger events.

Suggested change
scanner := bufio.NewScanner(f)
scanner := bufio.NewScanner(f)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)

Copilot uses AI. Check for mistakes.

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
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Malformed JSONL lines are silently skipped. This makes telemetry gaps hard to detect and debug; consider tracking the number of skipped lines and returning it in the ingest result (or logging it) so operators know data was dropped.

Copilot uses AI. Check for mistakes.

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
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR description claims events are “bulk inserted”, but ingestion currently performs one pool.Exec per line. Either update the description or implement batching (e.g., CopyFrom/batch/transaction) to match the stated design.

Copilot uses AI. Check for mistakes.
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
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inserting events with one pool.Exec per line will be a throughput bottleneck as event volume grows. Consider batching inserts in a transaction (or using pgx.CopyFrom) to reduce round-trips and improve ingest latency.

Copilot uses AI. Check for mistakes.
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 ""
}
Loading
Loading