diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..36a7c09 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,25 @@ +# Git +.git +.gitignore + +# GitHub +.github + +# Documentation +*.md +LICENSE + +# Compiled binaries +advanced_server +examples/advanced_server/advanced_server + +# IDE / editor +.vscode +.idea +*.swp +*.swo +*~ + +# OS files +.DS_Store +Thumbs.db diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 0000000..ebe9fac --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,73 @@ +name: Build and Push Docker Images + +on: + push: + branches: [main] + tags: ["v*"] + pull_request: + branches: [main] + +env: + REGISTRY: ghcr.io + IMAGE_PREFIX: ${{ github.repository }} + +jobs: + build-and-push: + runs-on: ubuntu-latest + + permissions: + contents: read + packages: write + + strategy: + fail-fast: false + matrix: + example: + - ab_testing + - advanced_server + - basic_rules + - content_filter + - pii_redactor + - user_blocking + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + if: github.event_name != 'pull_request' + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (tags, labels) + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}/${{ matrix.example }} + tags: | + type=ref,event=branch + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + type=sha + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + file: examples/${{ matrix.example }}/Dockerfile + platforms: linux/amd64,linux/arm64 + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/README.md b/README.md index e69de29..6ed05d3 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,80 @@ +# Hook Server Examples + +Example webhook servers implementing the CATE (Contextual Access for Tool Execution) hook system. These servers handle access control, pre-execution validation, and post-execution filtering for tool calls. + +## Examples + +### Advanced Server (Full-Featured with Web UI) + +A comprehensive server combining all features with a browser-based dashboard for configuration. + +- **[examples/advanced_server/](examples/advanced_server/)** - Access rules, PII redaction, A/B testing, and a web UI + +### Focused Examples (Single-Purpose, No UI) + +Minimal servers demonstrating individual hook capabilities: + +| Example | Description | Hook Points Used | +| ------- | ----------- | ---------------- | +| **[user_blocking](examples/user_blocking/)** | Block specific users from tools | Access, Pre | +| **[content_filter](examples/content_filter/)** | Filter/block based on content | Access, Pre, Post | +| **[pii_redactor](examples/pii_redactor/)** | Detect and redact PII in outputs | Post | +| **[ab_testing](examples/ab_testing/)** | A/B and canary test tool versions | Pre | +| **[basic_rules](examples/basic_rules/)** | Configurable rules for all hooks | Access, Pre, Post | + +## Quick Start + +```bash +# Run the advanced server with the web dashboard +go run ./examples/advanced_server -config ./examples/advanced_server/example-config.yaml + +# Or run a focused example +go run ./examples/pii_redactor -types "email,ssn,credit_card" +go run ./examples/user_blocking -block "user1,user2" +go run ./examples/content_filter -config ./examples/content_filter/example-config.yaml +go run ./examples/ab_testing -config ./examples/ab_testing/example-config.yaml +``` + +## Hook Points + +These servers implement webhook endpoints that integrate with an engine's hook system: + +| Endpoint | Purpose | +| -------- | ------- | +| `GET /health` | Health check | +| `POST /access` | Control which tools users can see | +| `POST /pre` | Validate/modify tool inputs before execution | +| `POST /post` | Validate/modify tool outputs after execution | + +## Architecture + +``` +Engine Request Flow +────────────────────────────────────────────────► + + ↓ ↓ ↓ +┌─────────┐ ┌─────────┐ ┌─────────┐ +│ ACCESS │ │ PRE │ │ POST │ +│ Hook │ │ Hook │ │ Hook │ +└────┬────┘ └────┬────┘ └────┬────┘ + │ │ │ + ▼ ▼ ▼ + Can user see Can user run Filter/modify + this tool? this tool? the output? +``` + +## Project Structure + +``` +├── examples/ +│ ├── advanced_server/ # Full-featured server with web UI +│ ├── basic_rules/ # Configurable rules (original example) +│ ├── user_blocking/ # Block specific users +│ ├── content_filter/ # Content-based filtering +│ ├── pii_redactor/ # PII detection and redaction +│ └── ab_testing/ # A/B and canary testing +├── pkg/ +│ └── server/ # Generated types from OpenAPI schema +├── go.mod +└── go.sum +``` diff --git a/advanced_server b/advanced_server new file mode 100755 index 0000000..91cc98b Binary files /dev/null and b/advanced_server differ diff --git a/examples/ab_testing/Dockerfile b/examples/ab_testing/Dockerfile new file mode 100644 index 0000000..7f9e1e6 --- /dev/null +++ b/examples/ab_testing/Dockerfile @@ -0,0 +1,25 @@ +FROM --platform=$BUILDPLATFORM golang:1.25-alpine AS builder + +ARG TARGETOS +ARG TARGETARCH + +WORKDIR /src + +# Cache dependency downloads +COPY go.mod go.sum ./ +RUN go mod download + +# Copy shared package and example source +COPY pkg/ pkg/ +COPY examples/ab_testing/ examples/ab_testing/ + +RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \ + go build -ldflags="-s -w" -trimpath -o /bin/server ./examples/ab_testing + +FROM gcr.io/distroless/static-debian12 + +COPY --from=builder /bin/server /bin/server + +EXPOSE 8080 + +ENTRYPOINT ["/bin/server"] diff --git a/examples/ab_testing/README.md b/examples/ab_testing/README.md new file mode 100644 index 0000000..d6d0935 --- /dev/null +++ b/examples/ab_testing/README.md @@ -0,0 +1,110 @@ +# A/B Testing Example + +A minimal hook server that demonstrates how to **A/B test and canary-deploy tool versions** by routing tool calls to different servers. + +## What It Shows + +- **Pre-execution hook**: Route tool calls to different servers/versions based on experiment config +- **Consistent hashing**: Same user always gets the same variant (sticky assignment) +- **Weighted traffic splitting**: Control what percentage of traffic goes to each variant +- **Tool registry integration**: Fetch available tools from an external API (e.g., [Arcade](https://docs.arcade.dev/en/references/api)) +- **Statistics tracking**: Monitor how many requests each variant receives + +## Quick Start + +```bash +# Run with experiment config +go run ./examples/ab_testing -config experiments.yaml +``` + +## Config File Format + +```yaml +# Optional: external tool registry for discovering tools +registry_url: "https://api.example.com" +registry_key: "your-api-key" + +experiments: + # Canary deployment: 10% traffic to new version + - name: "search-v2-canary" + enabled: true + toolkit: "Search" + tool: "WebSearch" + mode: canary + variants: + - name: "stable" + weight: 90 + version: "1.0.0" + - name: "canary" + weight: 10 + version: "2.0.0" + server_name: "search-v2" + server_uri: "http://search-v2.internal:8080" + server_type: "arcade" + + # 50/50 A/B test + - name: "email-provider-compare" + enabled: true + toolkit: "Email" + tool: "*" + mode: ab + variants: + - name: "provider-a" + weight: 50 + - name: "provider-b" + weight: 50 + server_name: "email-alt" + server_uri: "http://email-alt.internal:8080" + server_type: "arcade" +``` + +## How It Works + +### Variant Selection +1. When a tool call matches an active experiment (by toolkit and tool patterns), a variant is selected +2. Selection uses consistent hashing: `SHA256(user_id + ":" + experiment_name)` +3. The hash is mapped to a variant based on configured weights +4. The same user always gets the same variant for a given experiment + +### Server Routing +- If the selected variant has a `server_uri`, the pre-hook overrides the server routing +- This allows routing to different backend servers, different tool versions, etc. +- If no server override is specified, the tool executes normally (useful for tracking only) + +### Statistics +- GET `/stats` returns per-experiment, per-variant request counts +- This shows the actual traffic distribution across variants + +## Testing + +```bash +# Start with example config +go run ./examples/ab_testing -config experiments.yaml & + +# Send pre-hook requests for different users +for i in $(seq 1 20); do + curl -s -X POST http://localhost:8888/pre \ + -H "Content-Type: application/json" \ + -d "{ + \"execution_id\": \"exec-$i\", + \"tool\": {\"name\": \"WebSearch\", \"toolkit\": \"Search\", \"version\": \"1.0.0\"}, + \"context\": {\"user_id\": \"user-$i\"}, + \"inputs\": {\"query\": \"test\"} + }" | python3 -m json.tool + echo +done + +# Check statistics +curl -s http://localhost:8888/stats | python3 -m json.tool +``` + +## Tool Registry Integration + +The server can fetch available tools from an external tool registry API: + +```bash +# Configure registry URL in config, then fetch +curl -s -X POST http://localhost:8888/registry/fetch | python3 -m json.tool +``` + +This is useful for discovering what tools and versions are available before setting up experiments. diff --git a/examples/ab_testing/example-config.yaml b/examples/ab_testing/example-config.yaml new file mode 100644 index 0000000..7649769 --- /dev/null +++ b/examples/ab_testing/example-config.yaml @@ -0,0 +1,42 @@ +# A/B Testing Configuration +# +# Defines experiments for routing tool calls to different versions/servers. + +# Optional: external tool registry for discovering available tools +registry_url: "" +registry_key: "" + +experiments: + # Canary deployment: route 10% of traffic to a new search tool version + - name: "search-v2-canary" + enabled: true + toolkit: "Search" + tool: "WebSearch" + mode: canary + variants: + - name: "stable" + weight: 90 + version: "1.0.0" + - name: "canary" + weight: 10 + version: "2.0.0" + server_name: "search-v2" + server_uri: "http://search-v2.internal:8080" + server_type: "arcade" + + # 50/50 A/B test comparing two email tool implementations + - name: "email-provider-compare" + enabled: true + toolkit: "Email" + tool: "*" + mode: ab + variants: + - name: "provider-a" + weight: 50 + version: "1.0.0" + - name: "provider-b" + weight: 50 + version: "1.0.0" + server_name: "email-alt" + server_uri: "http://email-alt.internal:8080" + server_type: "arcade" diff --git a/examples/ab_testing/main.go b/examples/ab_testing/main.go new file mode 100644 index 0000000..3e57f06 --- /dev/null +++ b/examples/ab_testing/main.go @@ -0,0 +1,404 @@ +// ab_testing demonstrates how to do A/B and canary testing with tool calls. +// +// This minimal hook server shows: +// - Routing tool calls to different servers/versions based on experiment config +// - Consistent user assignment via deterministic hashing +// - Fetching available tools from an external tool registry API +// - Tracking experiment statistics +// +// Usage: +// +// go run ./examples/ab_testing -port 8888 -config experiments.yaml +package main + +import ( + "crypto/sha256" + "encoding/json" + "flag" + "fmt" + "io" + "log" + "net/http" + "os" + "regexp" + "strings" + "sync" + "time" + + "github.com/gin-gonic/gin" + "gopkg.in/yaml.v3" + + "github.com/ArcadeAI/logical-extensions-examples/pkg/server" +) + +// ============================================================================= +// Configuration +// ============================================================================= + +// Config defines A/B testing experiments. +type Config struct { + // RegistryURL is the base URL of an external tool registry API + RegistryURL string `yaml:"registry_url" json:"registry_url"` + // RegistryKey is the API key for the tool registry + RegistryKey string `yaml:"registry_key" json:"registry_key"` + // Experiments is the list of active experiments + Experiments []Experiment `yaml:"experiments" json:"experiments"` +} + +// Experiment defines a single A/B or canary test. +type Experiment struct { + Name string `yaml:"name" json:"name"` + Enabled bool `yaml:"enabled" json:"enabled"` + Toolkit string `yaml:"toolkit" json:"toolkit"` // glob pattern + Tool string `yaml:"tool" json:"tool"` // glob pattern + Mode string `yaml:"mode" json:"mode"` // "ab" or "canary" + Variants []Variant `yaml:"variants" json:"variants"` +} + +// Variant defines one arm of an experiment. +type Variant struct { + Name string `yaml:"name" json:"name"` + Weight int `yaml:"weight" json:"weight"` // relative weight (0-100) + Version string `yaml:"version" json:"version"` +} + +// ============================================================================= +// Server +// ============================================================================= + +// ABServer implements the webhook ServerInterface. +type ABServer struct { + mu sync.RWMutex + config *Config + token string + assignments map[string]string // "user:experiment" -> variant name + stats map[string]*ExperimentStats // experiment name -> stats +} + +// ExperimentStats tracks request counts per variant. +type ExperimentStats struct { + TotalRequests int `json:"total_requests"` + VariantCounts map[string]int `json:"variant_counts"` +} + +func NewABServer(cfg *Config, token string) *ABServer { + return &ABServer{ + config: cfg, + token: token, + assignments: make(map[string]string), + stats: make(map[string]*ExperimentStats), + } +} + +// HealthCheck implements ServerInterface. +func (s *ABServer) HealthCheck(c *gin.Context) { + status := server.Healthy + c.JSON(http.StatusOK, server.HealthResponse{Status: &status}) +} + +// AccessHook implements ServerInterface. Pass-through. +func (s *ABServer) AccessHook(c *gin.Context) { + if !s.validateAuth(c) { + return + } + var req server.AccessHookRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, server.ErrorResponse{ + Error: strPtr("invalid request body: " + err.Error()), + Code: responseCodePtr(server.CHECKFAILED), + }) + return + } + // A/B testing doesn't affect tool visibility + c.JSON(http.StatusOK, server.AccessHookResult{}) +} + +// PreHook implements ServerInterface. +// This is where A/B routing happens - route tool calls to different servers/versions. +func (s *ABServer) PreHook(c *gin.Context) { + if !s.validateAuth(c) { + return + } + + var req server.PreHookRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, server.ErrorResponse{ + Error: strPtr("invalid request body: " + err.Error()), + Code: responseCodePtr(server.CHECKFAILED), + }) + return + } + + userID := "" + if req.Context.UserId != nil { + userID = *req.Context.UserId + } + + // Find matching experiment + exp := s.findExperiment(req.Tool.Toolkit, req.Tool.Name) + if exp == nil { + // No experiment matches - pass through + c.JSON(http.StatusOK, server.PreHookResult{Code: server.OK}) + return + } + + // Select variant for this user + variant := s.selectVariant(userID, exp) + if variant == nil { + c.JSON(http.StatusOK, server.PreHookResult{Code: server.OK}) + return + } + + log.Printf("[A/B] %s: user=%q assigned to variant=%q (experiment=%q, mode=%s)", + req.Tool.Name, userID, variant.Name, exp.Name, exp.Mode) + + result := &server.PreHookResult{Code: server.OK} + + // A/B variant selected - pass through with OK + // (server routing overrides were removed from the schema; + // version filtering is handled at the access hook level) + + c.JSON(http.StatusOK, result) +} + +// PostHook implements ServerInterface. Pass-through. +func (s *ABServer) PostHook(c *gin.Context) { + if !s.validateAuth(c) { + return + } + var req server.PostHookRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, server.ErrorResponse{ + Error: strPtr("invalid request body: " + err.Error()), + Code: responseCodePtr(server.CHECKFAILED), + }) + return + } + // Pass-through - could be extended to log results per variant + c.JSON(http.StatusOK, server.PostHookResult{Code: server.OK}) +} + +// ============================================================================= +// Experiment Logic +// ============================================================================= + +// findExperiment returns the first enabled experiment matching the tool. +func (s *ABServer) findExperiment(toolkit, tool string) *Experiment { + for i, exp := range s.config.Experiments { + if !exp.Enabled { + continue + } + if matchGlob(exp.Toolkit, toolkit) && matchGlob(exp.Tool, tool) { + return &s.config.Experiments[i] + } + } + return nil +} + +// selectVariant picks a variant using consistent hashing for user stickiness. +func (s *ABServer) selectVariant(userID string, exp *Experiment) *Variant { + if len(exp.Variants) == 0 { + return nil + } + + // Check for existing assignment + key := userID + ":" + exp.Name + s.mu.RLock() + existingName, hasAssignment := s.assignments[key] + s.mu.RUnlock() + + if hasAssignment { + for i, v := range exp.Variants { + if v.Name == existingName { + s.recordStat(exp.Name, v.Name) + return &exp.Variants[i] + } + } + } + + // Consistent hash selection + totalWeight := 0 + for _, v := range exp.Variants { + totalWeight += v.Weight + } + if totalWeight == 0 { + return &exp.Variants[0] + } + + hash := sha256.Sum256([]byte(userID + ":" + exp.Name)) + hashVal := uint32(hash[0])<<24 | uint32(hash[1])<<16 | uint32(hash[2])<<8 | uint32(hash[3]) + target := int(hashVal % uint32(totalWeight)) + + cumulative := 0 + for i, v := range exp.Variants { + cumulative += v.Weight + if target < cumulative { + // Store assignment + s.mu.Lock() + s.assignments[key] = v.Name + s.mu.Unlock() + s.recordStat(exp.Name, v.Name) + return &exp.Variants[i] + } + } + + return &exp.Variants[len(exp.Variants)-1] +} + +func (s *ABServer) recordStat(expName, variantName string) { + s.mu.Lock() + defer s.mu.Unlock() + + stats, ok := s.stats[expName] + if !ok { + stats = &ExperimentStats{VariantCounts: make(map[string]int)} + s.stats[expName] = stats + } + stats.TotalRequests++ + stats.VariantCounts[variantName]++ +} + +// ============================================================================= +// Admin Endpoints +// ============================================================================= + +func (s *ABServer) handleGetStats(c *gin.Context) { + s.mu.RLock() + defer s.mu.RUnlock() + c.JSON(http.StatusOK, s.stats) +} + +func (s *ABServer) handleFetchTools(c *gin.Context) { + if s.config.RegistryURL == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "registry_url not configured"}) + return + } + + url := s.config.RegistryURL + "/v1/tools" + req, err := http.NewRequest("GET", url, nil) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if s.config.RegistryKey != "" { + req.Header.Set("Authorization", "Bearer "+s.config.RegistryKey) + } + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) + return + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + var tools interface{} + if err := json.Unmarshal(body, &tools); err != nil { + c.JSON(http.StatusBadGateway, gin.H{"error": "failed to parse registry response"}) + return + } + c.JSON(http.StatusOK, tools) +} + +// ============================================================================= +// Helpers +// ============================================================================= + +func (s *ABServer) validateAuth(c *gin.Context) bool { + if s.token == "" { + return true + } + auth := c.GetHeader("Authorization") + if auth != "Bearer "+s.token { + c.JSON(http.StatusUnauthorized, server.ErrorResponse{ + Error: strPtr("invalid or missing bearer token"), + Code: responseCodePtr(server.CHECKFAILED), + }) + return false + } + return true +} + +func matchGlob(pattern, value string) bool { + if pattern == "" || pattern == "*" { + return true + } + if strings.Contains(pattern, "*") { + regexPattern := "^" + strings.ReplaceAll(regexp.QuoteMeta(pattern), "\\*", ".*") + "$" + re, err := regexp.Compile(regexPattern) + if err != nil { + return false + } + return re.MatchString(value) + } + return pattern == value +} + +func strPtr(s string) *string { return &s } + +func responseCodePtr(c server.ResponseCode) *server.ResponseCode { return &c } + +// ============================================================================= +// Main +// ============================================================================= + +func main() { + var ( + port int + token string + configFile string + ) + + flag.IntVar(&port, "port", 8888, "Port to listen on") + flag.StringVar(&token, "token", "", "Bearer token for authentication") + flag.StringVar(&configFile, "config", "", "Path to YAML config with experiments") + flag.Parse() + + cfg := &Config{} + + if configFile != "" { + data, err := os.ReadFile(configFile) + if err != nil { + log.Fatalf("Failed to read config: %v", err) + } + if err := yaml.Unmarshal(data, cfg); err != nil { + log.Fatalf("Failed to parse config: %v", err) + } + log.Printf("Loaded %d experiments from %s", len(cfg.Experiments), configFile) + } else { + log.Println("No config file specified. Use -config to load experiments.") + } + + for _, exp := range cfg.Experiments { + status := "disabled" + if exp.Enabled { + status = "enabled" + } + log.Printf(" Experiment %q (%s): %s.%s mode=%s variants=%d", + exp.Name, status, exp.Toolkit, exp.Tool, exp.Mode, len(exp.Variants)) + } + + srv := NewABServer(cfg, token) + + gin.SetMode(gin.ReleaseMode) + router := gin.New() + router.Use(gin.Recovery()) + + // Webhook endpoints + server.RegisterHandlers(router, srv) + + // Admin endpoints + router.GET("/stats", srv.handleGetStats) + router.POST("/registry/fetch", srv.handleFetchTools) + + addr := fmt.Sprintf(":%d", port) + fmt.Printf("\nA/B Testing Hook Server listening on %s\n", addr) + fmt.Printf(" POST /pre - Route tool calls to experiment variants\n") + fmt.Printf(" GET /stats - View experiment statistics\n") + fmt.Printf(" POST /registry/fetch - Fetch tools from registry\n\n") + + if err := router.Run(addr); err != nil { + log.Fatal("Failed to start server:", err) + } +} diff --git a/examples/advanced_server/Dockerfile b/examples/advanced_server/Dockerfile new file mode 100644 index 0000000..16a978a --- /dev/null +++ b/examples/advanced_server/Dockerfile @@ -0,0 +1,25 @@ +FROM --platform=$BUILDPLATFORM golang:1.25-alpine AS builder + +ARG TARGETOS +ARG TARGETARCH + +WORKDIR /src + +# Cache dependency downloads +COPY go.mod go.sum ./ +RUN go mod download + +# Copy shared package and example source +COPY pkg/ pkg/ +COPY examples/advanced_server/ examples/advanced_server/ + +RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \ + go build -ldflags="-s -w" -trimpath -o /bin/server ./examples/advanced_server + +FROM gcr.io/distroless/static-debian12 + +COPY --from=builder /bin/server /bin/server + +EXPOSE 8080 + +ENTRYPOINT ["/bin/server"] diff --git a/examples/advanced_server/README.md b/examples/advanced_server/README.md new file mode 100644 index 0000000..34d16ef --- /dev/null +++ b/examples/advanced_server/README.md @@ -0,0 +1,167 @@ +# Advanced Hook Server + +A comprehensive hook server with a web dashboard for managing access rules, PII redaction, and A/B testing. All three hook points (access, pre-execution, post-execution) are fully supported with configurable behavior stored in a YAML file. + +## Features + +### 1. Basic Rules (Access, Pre, Post) +- **Access control**: Block users, toolkits, or specific tools from being visible +- **Pre-execution rules**: Block or modify tool requests before execution +- **Post-execution rules**: Block or modify tool responses after execution +- **Pattern matching**: Exact, glob (`*`), and regex (`~pattern`) patterns +- **Input/output matching**: Filter based on request content + +### 2. PII Redaction +- Automatically detect and handle PII in tool responses +- Supported PII types: email addresses, IPv4 addresses, SSNs, phone numbers, credit cards, dates of birth +- Custom regex patterns for organization-specific PII +- Choose to **redact** (replace with placeholders) or **block** (reject the response entirely) + +### 3. A/B & Canary Testing +- Route tool calls to different versions or servers +- Consistent user assignment via deterministic hashing +- Configure traffic splits (e.g., 90/10 canary, 50/50 A/B) +- Fetch available tools from an external tool registry API +- Real-time statistics dashboard + +### 4. Web Dashboard +- Configure all features through a browser-based UI +- Real-time request log viewer +- PII detection tester +- A/B experiment statistics +- Raw configuration editor + +## Quick Start + +```bash +# Run with defaults (port 8888, no auth) +go run ./examples/advanced_server + +# Run with a configuration file +go run ./examples/advanced_server -config ./examples/advanced_server/example-config.yaml + +# Run with authentication +go run ./examples/advanced_server -token "my-secret-token" + +# Run with TLS +go run ./examples/advanced_server -tls -cert server.crt -key server.key +``` + +Then open `http://localhost:8888/` in your browser to access the dashboard. + +## Command Line Flags + +| Flag | Default | Description | +| ---------- | ------------------ | ---------------------------------------------------- | +| `-port` | `8888` | Port to listen on | +| `-token` | `""` | Bearer token for authentication (empty = no auth) | +| `-verbose` | `true` | Log all requests to stdout | +| `-config` | `""` | Path to YAML configuration file (enables hot-reload) | +| `-tls` | `false` | Enable TLS/HTTPS | +| `-cert` | `""` | Path to server certificate file (PEM) | +| `-key` | `""` | Path to server private key file (PEM) | +| `-ca` | `""` | Path to CA certificate (enables mTLS) | + +## Configuration File + +The server stores its configuration in a YAML file (default: `hook-config.yaml`). Configuration can be modified through: + +1. **The web dashboard** at `/` +2. **The API** at `/api/config` +3. **Editing the YAML file** directly (auto-reloads) + +See [example-config.yaml](example-config.yaml) for a full example with all options. + +## Endpoints + +### Webhook Endpoints + +| Method | Path | Description | +| ------ | --------- | --------------------- | +| GET | `/health` | Health check endpoint | +| POST | `/access` | Access control hook | +| POST | `/pre` | Pre-execution hook | +| POST | `/post` | Post-execution hook | + +### Dashboard + +| Method | Path | Description | +| ------ | ---- | -------------- | +| GET | `/` | Web dashboard | + +### API Endpoints + +| Method | Path | Description | +| ---------- | --------------------- | ----------------------------------- | +| GET/PUT | `/api/config` | Get/update configuration | +| POST | `/api/config/save` | Save configuration to file | +| GET/DELETE | `/api/logs` | View/clear request logs | +| GET | `/api/status` | Server status | +| POST | `/api/registry/fetch` | Fetch tools from external registry | +| GET | `/api/ab/stats` | A/B testing statistics | +| DELETE | `/api/ab/stats` | Reset A/B statistics | +| POST | `/api/pii/test` | Test PII detection on sample text | + +## PII Redaction Details + +The PII redactor scans all string values in tool response outputs. When PII is detected: + +- **Redact mode**: Replaces PII with labeled placeholders (e.g., `[EMAIL REDACTED]`) +- **Block mode**: Returns an error response instead of the tool output + +### Supported PII Types + +| Type | Pattern Example | Redacted As | +| ------------ | ----------------------- | ------------------------ | +| Email | `user@example.com` | `[EMAIL REDACTED]` | +| IPv4 | `192.168.1.1` | `[IP REDACTED]` | +| SSN | `123-45-6789` | `[SSN REDACTED]` | +| Phone | `(555) 123-4567` | `[PHONE REDACTED]` | +| Credit Card | `4111-1111-1111-1111` | `[CREDIT CARD REDACTED]` | +| Date of Birth| `01/15/1990` | `[DOB REDACTED]` | + +Custom patterns can be added via the `pii.custom` configuration. + +## A/B Testing Details + +A/B testing works by intercepting tool calls in the pre-execution hook: + +1. When a tool call matches an active experiment, a variant is selected +2. Variant selection uses consistent hashing (SHA-256 of user_id + experiment_name) +3. This ensures the same user always gets the same variant +4. If the variant specifies an alternate server, the request is routed there +5. Statistics track how many requests each variant receives + +### Experiment Modes + +- **A/B**: Equal or weighted split between two variants for comparison +- **Canary**: Small percentage of traffic routed to new version for validation + +### Tool Registry Integration + +The server can fetch available tools from an external tool registry API (e.g., [Arcade](https://docs.arcade.dev/en/references/api)): + +1. Configure the registry URL and API key in the A/B Testing settings +2. Click "Fetch Available Tools" to discover tools and their versions +3. Use the discovered tools to set up experiments + +## Integration + +Configure a webhook plugin to point to this server: + +```yaml +plugins: + - type: webhook + name: advanced-hook + binding_type: org + config: + endpoints: + health: http://localhost:8888/health + access: http://localhost:8888/access + pre: http://localhost:8888/pre + post: http://localhost:8888/post + auth: + type: bearer + token: my-secret-token + timeout: 5s +``` diff --git a/examples/advanced_server/ab_testing.go b/examples/advanced_server/ab_testing.go new file mode 100644 index 0000000..1580ae1 --- /dev/null +++ b/examples/advanced_server/ab_testing.go @@ -0,0 +1,341 @@ +package main + +import ( + "crypto/sha256" + "encoding/json" + "fmt" + "io" + "net/http" + "sync" + "time" +) + +// ============================================================================= +// A/B Testing and Canary Testing +// ============================================================================= + +// ABTestManager manages experiment state and variant assignment. +type ABTestManager struct { + mu sync.RWMutex + assignments map[string]string // "user:experiment" -> variant name + stats map[string]*ExperimentStats // experiment name -> stats +} + +// ExperimentStats tracks usage statistics for an experiment. +type ExperimentStats struct { + Name string `json:"name"` + TotalRequests int `json:"total_requests"` + VariantCounts map[string]int `json:"variant_counts"` + UniqueUsers map[string]map[string]bool `json:"-"` // variant -> set of user IDs (not serialised) + VariantUsers map[string]int `json:"variant_users"` // variant -> unique user count + LastRequestTime *time.Time `json:"last_request_time,omitempty"` +} + +// NewABTestManager creates a new A/B test manager. +func NewABTestManager() *ABTestManager { + return &ABTestManager{ + assignments: make(map[string]string), + stats: make(map[string]*ExperimentStats), + } +} + +// SelectVariant picks a variant for a user based on experiment configuration. +// Uses consistent hashing to ensure the same user always gets the same variant. +func (m *ABTestManager) SelectVariant(userID string, exp Experiment) *Variant { + if len(exp.Variants) == 0 { + return nil + } + + // Check if user already has an assignment + assignmentKey := fmt.Sprintf("%s:%s", userID, exp.Name) + m.mu.RLock() + existingVariant, hasAssignment := m.assignments[assignmentKey] + m.mu.RUnlock() + + if hasAssignment { + // Return the previously assigned variant + for i, v := range exp.Variants { + if v.Name == existingVariant { + m.recordRequest(exp.Name, v.Name, userID) + return &exp.Variants[i] + } + } + } + + // Use consistent hashing to select a variant + variant := selectVariantByHash(userID, exp.Name, exp.Variants) + if variant == nil { + return nil + } + + // Store assignment for consistency + m.mu.Lock() + m.assignments[assignmentKey] = variant.Name + m.mu.Unlock() + + m.recordRequest(exp.Name, variant.Name, userID) + return variant +} + +// FindExperiment looks for an active experiment that matches the given tool. +func (m *ABTestManager) FindExperiment(toolkit, tool string, experiments []Experiment) *Experiment { + for i, exp := range experiments { + if !exp.Enabled { + continue + } + if matchesGlob(exp.Toolkit, toolkit) && matchesGlob(exp.Tool, tool) { + return &experiments[i] + } + } + return nil +} + +// GetStats returns stats for all experiments. +func (m *ABTestManager) GetStats() map[string]*ExperimentStats { + m.mu.RLock() + defer m.mu.RUnlock() + + result := make(map[string]*ExperimentStats, len(m.stats)) + for k, v := range m.stats { + statCopy := *v + vcCopy := make(map[string]int, len(v.VariantCounts)) + for vk, vv := range v.VariantCounts { + vcCopy[vk] = vv + } + statCopy.VariantCounts = vcCopy + + // Compute unique-user counts per variant + vuCopy := make(map[string]int, len(v.UniqueUsers)) + for vk, users := range v.UniqueUsers { + vuCopy[vk] = len(users) + } + statCopy.VariantUsers = vuCopy + + result[k] = &statCopy + } + return result +} + +// ResetStats clears all experiment statistics and assignments. +func (m *ABTestManager) ResetStats() { + m.mu.Lock() + defer m.mu.Unlock() + m.assignments = make(map[string]string) + m.stats = make(map[string]*ExperimentStats) +} + +func (m *ABTestManager) recordRequest(experimentName, variantName, userID string) { + m.mu.Lock() + defer m.mu.Unlock() + + stats, ok := m.stats[experimentName] + if !ok { + stats = &ExperimentStats{ + Name: experimentName, + VariantCounts: make(map[string]int), + UniqueUsers: make(map[string]map[string]bool), + } + m.stats[experimentName] = stats + } + + stats.TotalRequests++ + stats.VariantCounts[variantName]++ + + // Track unique users per variant + if stats.UniqueUsers == nil { + stats.UniqueUsers = make(map[string]map[string]bool) + } + if stats.UniqueUsers[variantName] == nil { + stats.UniqueUsers[variantName] = make(map[string]bool) + } + stats.UniqueUsers[variantName][userID] = true + + now := time.Now() + stats.LastRequestTime = &now +} + +// selectVariantByHash uses consistent hashing to deterministically +// pick a variant based on user ID and experiment name. +func selectVariantByHash(userID, experimentName string, variants []Variant) *Variant { + if len(variants) == 0 { + return nil + } + + // Calculate total weight + totalWeight := 0 + for _, v := range variants { + totalWeight += v.Weight + } + if totalWeight == 0 { + return &variants[0] + } + + // Create a deterministic hash from user + experiment + hash := sha256.Sum256([]byte(userID + ":" + experimentName)) + // Use first 4 bytes as a uint32 + hashVal := uint32(hash[0])<<24 | uint32(hash[1])<<16 | uint32(hash[2])<<8 | uint32(hash[3]) + + // Select variant based on weight + target := int(hashVal % uint32(totalWeight)) + cumulative := 0 + for i, v := range variants { + cumulative += v.Weight + if target < cumulative { + return &variants[i] + } + } + + return &variants[len(variants)-1] +} + +// ============================================================================= +// Tool Registry Client +// ============================================================================= + +// RegistryTool represents a tool in the dashboard-friendly format. +type RegistryTool struct { + Name string `json:"name"` + Toolkit string `json:"toolkit"` + Description string `json:"description"` + Versions []string `json:"versions"` +} + +// RegistryResponse is the response format returned to the dashboard. +type RegistryResponse struct { + Tools []RegistryTool `json:"tools"` + Total int `json:"total"` +} + +// arcadeToolResponse represents a single tool from the Arcade engine API. +type arcadeToolResponse struct { + Name string `json:"name"` + Description string `json:"description"` + FullyQualifiedName string `json:"fully_qualified_name"` + QualifiedName string `json:"qualified_name"` + Toolkit arcadeToolkitResponse `json:"toolkit"` +} + +// arcadeToolkitResponse represents toolkit info nested in a tool response. +type arcadeToolkitResponse struct { + Name string `json:"name"` + Description string `json:"description"` + Version string `json:"version"` +} + +// arcadeToolsPage is the paginated response from the Arcade engine's /v1/tools endpoint. +type arcadeToolsPage struct { + Items []arcadeToolResponse `json:"items"` + TotalCount int `json:"total_count"` + Limit int `json:"limit"` + Offset int `json:"offset"` + PageCount int `json:"page_count"` +} + +// FetchToolsFromRegistry queries the Arcade engine API for available tools. +// It handles pagination and transforms the engine response into a simpler format +// for the dashboard to display. +func FetchToolsFromRegistry(cfg *ToolRegistryConfig) (*RegistryResponse, error) { + if cfg == nil || cfg.BaseURL == "" { + return nil, fmt.Errorf("tool registry not configured: base_url is required") + } + + client := &http.Client{Timeout: 15 * time.Second} + var allTools []arcadeToolResponse + offset := 0 + limit := 100 + + for { + page, err := fetchToolsPage(client, cfg, limit, offset) + if err != nil { + return nil, err + } + + allTools = append(allTools, page.Items...) + + // Stop when we've fetched everything or the page was empty. + if len(allTools) >= page.TotalCount || len(page.Items) == 0 { + break + } + offset += len(page.Items) + } + + // Transform Arcade engine tools into RegistryTool format, grouping versions. + type toolKey struct { + toolkit string + toolName string + } + toolMap := make(map[toolKey]*RegistryTool) + var toolOrder []toolKey // preserve insertion order + + for _, t := range allTools { + key := toolKey{toolkit: t.Toolkit.Name, toolName: t.Name} + if rt, ok := toolMap[key]; ok { + // Add version if not already present. + if t.Toolkit.Version != "" { + found := false + for _, v := range rt.Versions { + if v == t.Toolkit.Version { + found = true + break + } + } + if !found { + rt.Versions = append(rt.Versions, t.Toolkit.Version) + } + } + } else { + rt := &RegistryTool{ + Name: t.Name, + Toolkit: t.Toolkit.Name, + Description: t.Description, + } + if t.Toolkit.Version != "" { + rt.Versions = []string{t.Toolkit.Version} + } + toolMap[key] = rt + toolOrder = append(toolOrder, key) + } + } + + tools := make([]RegistryTool, 0, len(toolOrder)) + for _, key := range toolOrder { + tools = append(tools, *toolMap[key]) + } + + return &RegistryResponse{ + Tools: tools, + Total: len(tools), + }, nil +} + +// fetchToolsPage fetches a single page of tools from the Arcade engine API. +func fetchToolsPage(client *http.Client, cfg *ToolRegistryConfig, limit, offset int) (*arcadeToolsPage, error) { + url := fmt.Sprintf("%s/v1/tools?limit=%d&offset=%d&include_all_versions=true", cfg.BaseURL, limit, offset) + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + if cfg.APIKey != "" { + req.Header.Set("Authorization", "Bearer "+cfg.APIKey) + } + req.Header.Set("Accept", "application/json") + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to fetch tools: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("registry returned status %d: %s", resp.StatusCode, string(body)) + } + + var page arcadeToolsPage + if err := json.NewDecoder(resp.Body).Decode(&page); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return &page, nil +} diff --git a/examples/advanced_server/advanced_server b/examples/advanced_server/advanced_server new file mode 100755 index 0000000..e211941 Binary files /dev/null and b/examples/advanced_server/advanced_server differ diff --git a/examples/advanced_server/config.go b/examples/advanced_server/config.go new file mode 100644 index 0000000..0bdcc47 --- /dev/null +++ b/examples/advanced_server/config.go @@ -0,0 +1,297 @@ +package main + +import ( + "fmt" + "os" + "sync" + + "gopkg.in/yaml.v3" +) + +// ============================================================================= +// Configuration Types +// ============================================================================= + +// Config is the root configuration loaded from YAML. +type Config struct { + Health *HealthConfig `yaml:"health" json:"health"` + Access *AccessConfig `yaml:"access" json:"access"` + Pre *PreConfig `yaml:"pre" json:"pre"` + Post *PostConfig `yaml:"post" json:"post"` + PII *PIIConfig `yaml:"pii" json:"pii"` + ABTesting *ABTestingConfig `yaml:"ab_testing" json:"ab_testing"` +} + +// HealthConfig controls health endpoint behavior. +type HealthConfig struct { + Status string `yaml:"status" json:"status"` // healthy, degraded, unhealthy +} + +// AccessConfig controls access hook behavior. +type AccessConfig struct { + DefaultAction string `yaml:"default_action" json:"default_action"` + Rules []AccessRule `yaml:"rules" json:"rules"` +} + +// AccessRule defines a single access control rule. +type AccessRule struct { + UserID string `yaml:"user_id" json:"user_id"` + Toolkit string `yaml:"toolkit" json:"toolkit"` + Tool string `yaml:"tool" json:"tool"` + Action string `yaml:"action" json:"action"` + Reason string `yaml:"reason" json:"reason"` +} + +// PreConfig controls pre-execution hook behavior. +type PreConfig struct { + DefaultAction string `yaml:"default_action" json:"default_action"` + Rules []PreRule `yaml:"rules" json:"rules"` +} + +// PreRule defines a single pre-execution rule. +type PreRule struct { + UserID string `yaml:"user_id" json:"user_id"` + Toolkit string `yaml:"toolkit" json:"toolkit"` + Tool string `yaml:"tool" json:"tool"` + ExecutionID string `yaml:"execution_id" json:"execution_id"` + InputMatch string `yaml:"input_match" json:"input_match"` + Action string `yaml:"action" json:"action"` + ErrorMessage string `yaml:"error_message" json:"error_message"` + Override *PreOverrideConfig `yaml:"override" json:"override"` +} + +// PreOverrideConfig defines what to override in pre-hook. +type PreOverrideConfig struct { + Inputs map[string]interface{} `yaml:"inputs" json:"inputs"` + Secrets map[string]string `yaml:"secrets" json:"secrets"` +} + +// PostConfig controls post-execution hook behavior. +type PostConfig struct { + DefaultAction string `yaml:"default_action" json:"default_action"` + Rules []PostRule `yaml:"rules" json:"rules"` +} + +// PostRule defines a single post-execution rule. +type PostRule struct { + UserID string `yaml:"user_id" json:"user_id"` + Toolkit string `yaml:"toolkit" json:"toolkit"` + Tool string `yaml:"tool" json:"tool"` + ExecutionID string `yaml:"execution_id" json:"execution_id"` + Success *bool `yaml:"success" json:"success"` + OutputMatch string `yaml:"output_match" json:"output_match"` + Action string `yaml:"action" json:"action"` + ErrorMessage string `yaml:"error_message" json:"error_message"` + Override *PostOverrideConfig `yaml:"override" json:"override"` +} + +// PostOverrideConfig defines what to override in post-hook. +type PostOverrideConfig struct { + Output map[string]interface{} `yaml:"output" json:"output"` +} + +// PIIConfig controls PII redaction behavior. +type PIIConfig struct { + Enabled bool `yaml:"enabled" json:"enabled"` + Action string `yaml:"action" json:"action"` // "redact" or "block" + Types PIITypes `yaml:"types" json:"types"` + Custom []PIICustomPattern `yaml:"custom" json:"custom"` +} + +// PIITypes controls which PII types to detect. +type PIITypes struct { + Email bool `yaml:"email" json:"email"` + IPv4 bool `yaml:"ipv4" json:"ipv4"` + SSN bool `yaml:"ssn" json:"ssn"` + Phone bool `yaml:"phone" json:"phone"` + CreditCard bool `yaml:"credit_card" json:"credit_card"` + DateOfBirth bool `yaml:"date_of_birth" json:"date_of_birth"` +} + +// PIICustomPattern defines a custom PII detection pattern. +type PIICustomPattern struct { + Name string `yaml:"name" json:"name"` + Pattern string `yaml:"pattern" json:"pattern"` + Replacement string `yaml:"replacement" json:"replacement"` +} + +// ABTestingConfig controls A/B and canary testing. +type ABTestingConfig struct { + Enabled bool `yaml:"enabled" json:"enabled"` + ToolRegistry *ToolRegistryConfig `yaml:"tool_registry" json:"tool_registry"` + Experiments []Experiment `yaml:"experiments" json:"experiments"` +} + +// ToolRegistryConfig configures the external tool registry API. +type ToolRegistryConfig struct { + BaseURL string `yaml:"base_url" json:"base_url"` + APIKey string `yaml:"api_key" json:"api_key"` +} + +// Experiment defines an A/B or canary test. +type Experiment struct { + Name string `yaml:"name" json:"name"` + Enabled bool `yaml:"enabled" json:"enabled"` + Toolkit string `yaml:"toolkit" json:"toolkit"` + Tool string `yaml:"tool" json:"tool"` + Mode string `yaml:"mode" json:"mode"` // "ab" or "canary" + Variants []Variant `yaml:"variants" json:"variants"` +} + +// Variant defines a single variant in an experiment. +type Variant struct { + Name string `yaml:"name" json:"name"` + Weight int `yaml:"weight" json:"weight"` // 0-100, relative weight + Version string `yaml:"version" json:"version"` +} + +// ============================================================================= +// Configuration Manager +// ============================================================================= + +// ConfigManager handles loading, saving, and thread-safe access to configuration. +type ConfigManager struct { + mu sync.RWMutex + config *Config + configPath string +} + +// NewConfigManager creates a new ConfigManager with default configuration. +func NewConfigManager(configPath string) *ConfigManager { + return &ConfigManager{ + configPath: configPath, + config: DefaultConfig(), + } +} + +// DefaultConfig returns a configuration with sensible defaults. +func DefaultConfig() *Config { + return &Config{ + Health: &HealthConfig{Status: "healthy"}, + Access: &AccessConfig{ + DefaultAction: "allow", + Rules: []AccessRule{}, + }, + Pre: &PreConfig{ + DefaultAction: "proceed", + Rules: []PreRule{}, + }, + Post: &PostConfig{ + DefaultAction: "proceed", + Rules: []PostRule{}, + }, + PII: &PIIConfig{ + Enabled: false, + Action: "redact", + Types: PIITypes{ + Email: true, + IPv4: true, + SSN: true, + Phone: true, + CreditCard: true, + DateOfBirth: false, + }, + Custom: []PIICustomPattern{}, + }, + ABTesting: &ABTestingConfig{ + Enabled: false, + ToolRegistry: &ToolRegistryConfig{ + BaseURL: "", + APIKey: "", + }, + Experiments: []Experiment{}, + }, + } +} + +// Load reads configuration from the YAML file at configPath. +func (cm *ConfigManager) Load() error { + data, err := os.ReadFile(cm.configPath) + if err != nil { + return fmt.Errorf("failed to read config file: %w", err) + } + + var cfg Config + if err := yaml.Unmarshal(data, &cfg); err != nil { + return fmt.Errorf("failed to parse config file: %w", err) + } + + cm.mu.Lock() + defer cm.mu.Unlock() + + // Merge with defaults - only override non-nil fields + if cfg.Health != nil { + cm.config.Health = cfg.Health + } + if cfg.Access != nil { + cm.config.Access = cfg.Access + } + if cfg.Pre != nil { + cm.config.Pre = cfg.Pre + } + if cfg.Post != nil { + cm.config.Post = cfg.Post + } + if cfg.PII != nil { + cm.config.PII = cfg.PII + } + if cfg.ABTesting != nil { + cm.config.ABTesting = cfg.ABTesting + } + + return nil +} + +// Save writes the current configuration to the YAML file at configPath. +func (cm *ConfigManager) Save() error { + cm.mu.RLock() + data, err := yaml.Marshal(cm.config) + cm.mu.RUnlock() + + if err != nil { + return fmt.Errorf("failed to marshal config: %w", err) + } + + if err := os.WriteFile(cm.configPath, data, 0o644); err != nil { + return fmt.Errorf("failed to write config file: %w", err) + } + + return nil +} + +// Get returns the current configuration (read-only snapshot). +func (cm *ConfigManager) Get() *Config { + cm.mu.RLock() + defer cm.mu.RUnlock() + return cm.config +} + +// Update replaces the current configuration with the provided one, merging non-nil fields. +func (cm *ConfigManager) Update(cfg *Config) { + cm.mu.Lock() + defer cm.mu.Unlock() + + if cfg.Health != nil { + cm.config.Health = cfg.Health + } + if cfg.Access != nil { + cm.config.Access = cfg.Access + } + if cfg.Pre != nil { + cm.config.Pre = cfg.Pre + } + if cfg.Post != nil { + cm.config.Post = cfg.Post + } + if cfg.PII != nil { + cm.config.PII = cfg.PII + } + if cfg.ABTesting != nil { + cm.config.ABTesting = cfg.ABTesting + } +} + +// ConfigPath returns the path to the configuration file. +func (cm *ConfigManager) ConfigPath() string { + return cm.configPath +} diff --git a/examples/advanced_server/example-config.yaml b/examples/advanced_server/example-config.yaml new file mode 100644 index 0000000..97ac877 --- /dev/null +++ b/examples/advanced_server/example-config.yaml @@ -0,0 +1,178 @@ +# Advanced Hook Server Configuration +# +# This file demonstrates all available configuration options. +# The server hot-reloads this file when it changes. +# +# Usage: go run ./examples/advanced_server -config example-config.yaml + +# Health endpoint configuration +health: + status: healthy # Options: healthy, degraded, unhealthy + +# Access control hook configuration +# Determines which tools a user can see/use +access: + default_action: allow # "allow" or "deny" when no rule matches + + rules: + # Block a specific user from all tools + - user_id: "blocked-user" + action: deny + reason: "Account suspended" + + # Block a specific toolkit + - toolkit: "DangerousToolkit" + action: deny + reason: "Toolkit disabled by admin" + + # Block a specific tool + - toolkit: "Email" + tool: "SendBulk" + action: deny + reason: "Bulk email is disabled" + + # Allow admin users access to admin tools + - user_id: "~^admin-.*" # Regex: users starting with "admin-" + toolkit: "Admin*" # Glob: toolkits starting with "Admin" + action: allow + + # Deny test users from production tools + - user_id: "~^test-.*" + toolkit: "Production*" + action: deny + reason: "Test users cannot access production tools" + +# Pre-execution hook configuration +# Runs before a tool is executed - can block or modify the request +pre: + default_action: proceed # "proceed", "block", or "rate_limit" + + rules: + # Block emails to certain domains + - toolkit: "Email" + tool: "SendEmail" + input_match: "to contains @blocked.com" + action: block + error_message: "Sending to blocked.com is not allowed" + + # Rate limit search requests + - toolkit: "Search" + tool: "WebSearch" + action: rate_limit + error_message: "Too many search requests" + + # Modify inputs before execution + - toolkit: "FileSystem" + tool: "WriteFile" + action: proceed + override: + inputs: + base_path: "/sandbox/" + headers: + X-Sandbox-Mode: "true" + + # Inject secrets for database tools + - toolkit: "Database" + tool: "*" + action: proceed + override: + secrets: + DB_PASSWORD: "secure-password-123" + DB_HOST: "db.internal:5432" + + # Override server routing for test users + - user_id: "test-user" + action: proceed + override: + server: + name: "test-worker" + uri: "http://localhost:9999" + type: "arcade" + +# Post-execution hook configuration +# Runs after tool execution - can block or modify the response +post: + default_action: proceed + + rules: + # Block failed executions + - success: false + action: block + error_message: "Tool execution failed - response blocked" + + # Redact sensitive data from database queries + - toolkit: "Database" + tool: "Query" + action: proceed + override: + output: + data: "[REDACTED]" + row_count: 0 + +# PII Redaction configuration +# Automatically detects and handles PII in tool responses +pii: + enabled: true + action: redact # "redact" replaces PII with placeholders; "block" rejects the response + + # Which PII types to detect + types: + email: true # email@example.com + ipv4: true # 192.168.1.1 + ssn: true # 123-45-6789 + phone: true # (555) 123-4567 + credit_card: true # 4111-1111-1111-1111 + date_of_birth: false # 01/15/1990 + + # Custom PII patterns (regex) + custom: + - name: "employee_id" + pattern: "EMP-\\d{6}" + replacement: "[EMPLOYEE ID REDACTED]" + +# A/B and Canary Testing configuration +ab_testing: + enabled: true + + # External tool registry for discovering available tools and versions + tool_registry: + base_url: "" # e.g., https://api.example.com + api_key: "" # API key for authentication + + # Experiment definitions + experiments: + # Example: canary test for a new search tool version + - name: "search-v2-canary" + enabled: true + toolkit: "Search" + tool: "WebSearch" + mode: canary # "ab" for A/B test, "canary" for gradual rollout + variants: + - name: "stable" + weight: 90 + version: "1.0.0" + - name: "canary" + weight: 10 + version: "2.0.0" + server: + name: "search-v2" + uri: "http://search-v2.internal:8080" + type: "arcade" + + # Example: A/B test comparing two email tool implementations + - name: "email-provider-test" + enabled: false + toolkit: "Email" + tool: "SendEmail" + mode: ab + variants: + - name: "provider-a" + weight: 50 + version: "1.0.0" + - name: "provider-b" + weight: 50 + version: "1.0.0" + server: + name: "email-alt" + uri: "http://email-alt.internal:8080" + type: "arcade" diff --git a/examples/advanced_server/main.go b/examples/advanced_server/main.go new file mode 100644 index 0000000..60137ba --- /dev/null +++ b/examples/advanced_server/main.go @@ -0,0 +1,1070 @@ +// advanced_server is a comprehensive hook server with a web UI for configuring: +// - Basic access/pre/post rules (blocking users, filtering data, content-based blocking) +// - PII redaction (emails, IPs, SSNs, phone numbers, credit cards, dates of birth) +// - A/B and canary testing (route tool calls to different versions/servers) +// +// Configuration is stored in a YAML file with hot-reload support. +// All features can be managed through the built-in web dashboard. +// +// Usage: +// +// go run ./examples/advanced_server -port 8888 -config config.yaml +// go run ./examples/advanced_server -port 8888 -token secret123 +package main + +import ( + "context" + "crypto/tls" + "crypto/x509" + "encoding/json" + "errors" + "flag" + "fmt" + "log" + "net/http" + "os" + "os/signal" + "regexp" + "strings" + "sync" + "syscall" + "time" + + "github.com/fsnotify/fsnotify" + "github.com/gin-gonic/gin" + "gopkg.in/yaml.v3" + + "github.com/ArcadeAI/logical-extensions-examples/pkg/server" +) + +// ============================================================================= +// Request Logging +// ============================================================================= + +// RequestLog stores information about each incoming request. +type RequestLog struct { + Timestamp time.Time `json:"timestamp"` + Endpoint string `json:"endpoint"` + Body interface{} `json:"body"` + Response interface{} `json:"response"` + RuleMatch string `json:"rule_match,omitempty"` + PIIFound bool `json:"pii_found,omitempty"` + ABVariant string `json:"ab_variant,omitempty"` +} + +// ============================================================================= +// Server +// ============================================================================= + +// ServerConfig holds CLI configuration for the server. +type ServerConfig struct { + Port int + Token string + Verbose bool + ConfigFile string + + // TLS/mTLS + TLSEnabled bool + CertFile string + KeyFile string + CAFile string +} + +// HookServer implements the webhook ServerInterface with rules, PII redaction, and A/B testing. +type HookServer struct { + mu sync.RWMutex + logs []RequestLog + serverCfg *ServerConfig + cfgMgr *ConfigManager + abMgr *ABTestManager +} + +// NewHookServer creates a new server instance. +func NewHookServer(serverCfg *ServerConfig, cfgMgr *ConfigManager) *HookServer { + return &HookServer{ + logs: make([]RequestLog, 0), + serverCfg: serverCfg, + cfgMgr: cfgMgr, + abMgr: NewABTestManager(), + } +} + +func (s *HookServer) logRequest(endpoint string, body, response interface{}, ruleMatch string) { + s.mu.Lock() + defer s.mu.Unlock() + + entry := RequestLog{ + Timestamp: time.Now(), + Endpoint: endpoint, + Body: body, + Response: response, + RuleMatch: ruleMatch, + } + s.logs = append(s.logs, entry) + + if s.serverCfg.Verbose { + jsonBody, _ := json.MarshalIndent(body, "", " ") + jsonResp, _ := json.MarshalIndent(response, "", " ") + fmt.Printf("\n[%s] %s\n", time.Now().Format("15:04:05"), endpoint) + if ruleMatch != "" { + fmt.Printf("Rule matched: %s\n", ruleMatch) + } + fmt.Printf("Request:\n%s\n", string(jsonBody)) + fmt.Printf("Response:\n%s\n", string(jsonResp)) + fmt.Println(strings.Repeat("-", 60)) + } +} + +func (s *HookServer) logRequestFull(endpoint string, body, response interface{}, ruleMatch string, piiFound bool, abVariant string) { + s.mu.Lock() + defer s.mu.Unlock() + + entry := RequestLog{ + Timestamp: time.Now(), + Endpoint: endpoint, + Body: body, + Response: response, + RuleMatch: ruleMatch, + PIIFound: piiFound, + ABVariant: abVariant, + } + s.logs = append(s.logs, entry) + + if s.serverCfg.Verbose { + jsonBody, _ := json.MarshalIndent(body, "", " ") + jsonResp, _ := json.MarshalIndent(response, "", " ") + fmt.Printf("\n[%s] %s\n", time.Now().Format("15:04:05"), endpoint) + if ruleMatch != "" { + fmt.Printf("Rule matched: %s\n", ruleMatch) + } + if piiFound { + fmt.Printf("PII detected and handled\n") + } + if abVariant != "" { + fmt.Printf("A/B variant: %s\n", abVariant) + } + fmt.Printf("Request:\n%s\n", string(jsonBody)) + fmt.Printf("Response:\n%s\n", string(jsonResp)) + fmt.Println(strings.Repeat("-", 60)) + } +} + +// GetLogs returns all logged requests. +func (s *HookServer) GetLogs() []RequestLog { + s.mu.RLock() + defer s.mu.RUnlock() + return append([]RequestLog{}, s.logs...) +} + +// ClearLogs clears all logged requests. +func (s *HookServer) ClearLogs() { + s.mu.Lock() + defer s.mu.Unlock() + s.logs = make([]RequestLog, 0) +} + +// ============================================================================= +// Webhook Handlers (ServerInterface implementation) +// ============================================================================= + +// HealthCheck implements webhook.ServerInterface. +func (s *HookServer) HealthCheck(c *gin.Context) { + cfg := s.cfgMgr.Get() + status := server.HealthResponseStatus(cfg.Health.Status) + resp := server.HealthResponse{Status: &status} + + s.logRequest("/health", nil, resp, "") + c.JSON(http.StatusOK, resp) +} + +// AccessHook implements webhook.ServerInterface. +func (s *HookServer) AccessHook(c *gin.Context) { + if !s.validateAuth(c) { + return + } + + var req server.AccessHookRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, server.ErrorResponse{ + Error: strPtr("invalid request body: " + err.Error()), + Code: responseCodePtr(server.CHECKFAILED), + }) + return + } + + resp, ruleMatch, abVariant := s.evaluateAccessRules(req) + s.logRequestFull("/access", req, resp, ruleMatch, false, abVariant) + c.JSON(http.StatusOK, resp) +} + +// PreHook implements webhook.ServerInterface. +func (s *HookServer) PreHook(c *gin.Context) { + if !s.validateAuth(c) { + return + } + + var req server.PreHookRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, server.ErrorResponse{ + Error: strPtr("invalid request body: " + err.Error()), + Code: responseCodePtr(server.CHECKFAILED), + }) + return + } + + resp, ruleMatch, abVariant := s.evaluatePreRules(req) + s.logRequestFull("/pre", req, resp, ruleMatch, false, abVariant) + c.JSON(http.StatusOK, resp) +} + +// PostHook implements webhook.ServerInterface. +func (s *HookServer) PostHook(c *gin.Context) { + if !s.validateAuth(c) { + return + } + + var req server.PostHookRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, server.ErrorResponse{ + Error: strPtr("invalid request body: " + err.Error()), + Code: responseCodePtr(server.CHECKFAILED), + }) + return + } + + resp, ruleMatch, piiFound := s.evaluatePostRules(req) + s.logRequestFull("/post", req, resp, ruleMatch, piiFound, "") + c.JSON(http.StatusOK, resp) +} + +// ============================================================================= +// Access Hook Evaluation (with A/B version filtering) +// ============================================================================= + +func (s *HookServer) evaluateAccessRules(req server.AccessHookRequest) (*server.AccessHookResult, string, string) { + cfg := s.cfgMgr.Get() + accessCfg := cfg.Access + + allow := make(server.Toolkits) + deny := make(server.Toolkits) + ruleMatch := "" + abVariant := "" + + for toolkitName, toolkitInfo := range req.Toolkits { + if toolkitInfo.Tools == nil { + continue + } + for toolName, versions := range *toolkitInfo.Tools { + action, matched := s.matchAccessRule(accessCfg, req.UserId, toolkitName, toolName) + if matched != "" { + ruleMatch = matched + } + + if action == "deny" { + if _, ok := deny[toolkitName]; !ok { + deny[toolkitName] = server.ToolkitInfo{Tools: &map[string][]server.ToolVersionInfo{}} + } + (*deny[toolkitName].Tools)[toolName] = versions + } else { + // Apply A/B version filtering for allowed tools + filteredVersions, variantName := s.applyABVersionFilter(cfg, req.UserId, toolkitName, toolName, versions) + if variantName != "" { + abVariant = variantName + } + + if _, ok := allow[toolkitName]; !ok { + allow[toolkitName] = server.ToolkitInfo{Tools: &map[string][]server.ToolVersionInfo{}} + } + (*allow[toolkitName].Tools)[toolName] = filteredVersions + } + } + } + + result := &server.AccessHookResult{} + if len(allow) > 0 { + result.Only = &allow + } + if len(deny) > 0 { + result.Deny = &deny + } + + return result, ruleMatch, abVariant +} + +// applyABVersionFilter checks if an A/B experiment applies to this tool and +// filters the available versions based on the selected variant. +func (s *HookServer) applyABVersionFilter(cfg *Config, userID, toolkit, tool string, versions []server.ToolVersionInfo) ([]server.ToolVersionInfo, string) { + if cfg.ABTesting == nil || !cfg.ABTesting.Enabled || userID == "" { + return versions, "" + } + + exp := s.abMgr.FindExperiment(toolkit, tool, cfg.ABTesting.Experiments) + if exp == nil { + return versions, "" + } + + variant := s.abMgr.SelectVariant(userID, *exp) + if variant == nil || variant.Version == "" { + return versions, "" + } + + // Filter to only include the variant's version + var filtered []server.ToolVersionInfo + for _, v := range versions { + if v.Version != nil && *v.Version == variant.Version { + filtered = append(filtered, v) + } + } + + // If no matching version found, pass through all versions rather than breaking + if len(filtered) == 0 { + return versions, variant.Name + } + + return filtered, variant.Name +} + +func (s *HookServer) matchAccessRule(cfg *AccessConfig, userID, toolkit, tool string) (string, string) { + for i, rule := range cfg.Rules { + if matchesPattern(rule.UserID, userID) && + matchesPattern(rule.Toolkit, toolkit) && + matchesPattern(rule.Tool, tool) { + return rule.Action, fmt.Sprintf("access.rules[%d]", i) + } + } + return cfg.DefaultAction, "" +} + +// ============================================================================= +// Pre-Hook Evaluation (A/B server routing only; version filtering is in access hook) +// ============================================================================= + +func (s *HookServer) evaluatePreRules(req server.PreHookRequest) (*server.PreHookResult, string, string) { + cfg := s.cfgMgr.Get() + preCfg := cfg.Pre + + userID := "" + if req.Context.UserId != nil { + userID = *req.Context.UserId + } + + // First evaluate basic rules + for i, rule := range preCfg.Rules { + if s.matchPreRule(rule, userID, req) { + result := s.applyPreRule(rule) + return result, fmt.Sprintf("pre.rules[%d]", i), "" + } + } + + // Check A/B testing - server routing only (version filtering is handled by access hook) + abVariant := "" + if cfg.ABTesting != nil && cfg.ABTesting.Enabled && userID != "" { + exp := s.abMgr.FindExperiment(req.Tool.Toolkit, req.Tool.Name, cfg.ABTesting.Experiments) + if exp != nil { + variant := s.abMgr.SelectVariant(userID, *exp) + if variant != nil { + abVariant = variant.Name + + // A/B testing matched - return OK with variant info + // (server routing overrides were removed from the schema; + // version filtering is done at the access hook level) + return &server.PreHookResult{Code: server.OK}, fmt.Sprintf("ab:%s->%s", exp.Name, variant.Name), abVariant + } + } + } + + // Default action + return &server.PreHookResult{ + Code: actionToCode(preCfg.DefaultAction), + }, "", abVariant +} + +func (s *HookServer) matchPreRule(rule PreRule, userID string, req server.PreHookRequest) bool { + if !matchesPattern(rule.UserID, userID) { + return false + } + if !matchesPattern(rule.Toolkit, req.Tool.Toolkit) { + return false + } + if !matchesPattern(rule.Tool, req.Tool.Name) { + return false + } + if !matchesPattern(rule.ExecutionID, req.ExecutionId) { + return false + } + if rule.InputMatch != "" && !matchesInputs(rule.InputMatch, req.Inputs) { + return false + } + return true +} + +func (s *HookServer) applyPreRule(rule PreRule) *server.PreHookResult { + result := &server.PreHookResult{ + Code: actionToCode(rule.Action), + } + + if rule.ErrorMessage != "" { + result.ErrorMessage = &rule.ErrorMessage + } + + if rule.Override != nil && rule.Action == "proceed" { + override := &server.PreHookOverride{} + if len(rule.Override.Inputs) > 0 { + override.Inputs = &rule.Override.Inputs + } + if len(rule.Override.Secrets) > 0 { + secrets := []map[string]string{rule.Override.Secrets} + override.Secrets = &secrets + } + result.Override = override + } + + return result +} + +// ============================================================================= +// Post-Hook Evaluation (with PII redaction) +// ============================================================================= + +func (s *HookServer) evaluatePostRules(req server.PostHookRequest) (*server.PostHookResult, string, bool) { + cfg := s.cfgMgr.Get() + postCfg := cfg.Post + + userID := "" + if req.Context.UserId != nil { + userID = *req.Context.UserId + } + + // Evaluate basic rules first to get the base result + var result *server.PostHookResult + ruleMatch := "" + for i, rule := range postCfg.Rules { + if s.matchPostRule(rule, userID, req) { + result = s.applyPostRule(rule) + ruleMatch = fmt.Sprintf("post.rules[%d]", i) + break + } + } + + // If no rule matched, use default action + if result == nil { + result = &server.PostHookResult{ + Code: actionToCode(postCfg.DefaultAction), + } + } + + // Always apply PII redaction on top of whatever result we have. + // PII is a security/compliance feature and should never be bypassed by rules. + // Scan both inputs and output for PII — inputs may contain sensitive data + // that the tool could echo back, and output may not always be populated. + piiFound := false + if cfg.PII != nil && cfg.PII.Enabled { + hasContent := req.Output != nil || (req.Inputs != nil && len(*req.Inputs) > 0) + if hasContent { + detector := NewPIIDetector(cfg.PII) + + // Scan both output and inputs for PII + var outputScan, inputScan PIIScanResult + if req.Output != nil { + outputScan = detector.ScanAndSummarizeAny(req.Output) + } + if req.Inputs != nil { + inputScan = detector.ScanAndSummarizeAny(*req.Inputs) + } + + if outputScan.ContainsPII || inputScan.ContainsPII { + piiFound = true + + if cfg.PII.Action == "block" { + // Block the response entirely, regardless of rule result + errMsg := "Response blocked: PII detected" + if outputScan.ContainsPII && inputScan.ContainsPII { + errMsg = "Response blocked: PII detected in inputs and output" + } else if inputScan.ContainsPII { + errMsg = "Response blocked: PII detected in inputs" + } else { + errMsg = "Response blocked: PII detected in output" + } + return &server.PostHookResult{ + Code: server.CHECKFAILED, + ErrorMessage: &errMsg, + }, joinRuleMatch(ruleMatch, "pii:block"), true + } + + // Redact PII from output (or from inputs if output is nil) + var outputToRedact interface{} = req.Output + if result.Override != nil && result.Override.Output != nil { + outputToRedact = result.Override.Output + } + // If output is nil but inputs have PII, redact the inputs and + // return them as the output override so the caller sees redacted data. + if outputToRedact == nil && req.Inputs != nil { + m := map[string]interface{}(*req.Inputs) + outputToRedact = m + } + + if outputToRedact != nil { + redacted := detector.RedactAny(outputToRedact) + if result.Override == nil { + result.Override = &server.PostHookOverride{} + } + result.Override.Output = redacted + } + return result, joinRuleMatch(ruleMatch, "pii:redact"), true + } + } + } + + return result, ruleMatch, piiFound +} + +func (s *HookServer) matchPostRule(rule PostRule, userID string, req server.PostHookRequest) bool { + if !matchesPattern(rule.UserID, userID) { + return false + } + if !matchesPattern(rule.Toolkit, req.Tool.Toolkit) { + return false + } + if !matchesPattern(rule.Tool, req.Tool.Name) { + return false + } + if !matchesPattern(rule.ExecutionID, req.ExecutionId) { + return false + } + if rule.Success != nil && req.Success != nil && *rule.Success != *req.Success { + return false + } + if rule.OutputMatch != "" { + if outputMap, ok := req.Output.(map[string]interface{}); ok { + if !matchesInputs(rule.OutputMatch, outputMap) { + return false + } + } + } + return true +} + +func (s *HookServer) applyPostRule(rule PostRule) *server.PostHookResult { + result := &server.PostHookResult{ + Code: actionToCode(rule.Action), + } + + if rule.ErrorMessage != "" { + result.ErrorMessage = &rule.ErrorMessage + } + + if rule.Override != nil && rule.Action == "proceed" { + if len(rule.Override.Output) > 0 { + output := map[string]interface{}(rule.Override.Output) + result.Override = &server.PostHookOverride{ + Output: output, + } + } + } + + return result +} + +// ============================================================================= +// Admin/API Handlers +// ============================================================================= + +func (s *HookServer) handleGetConfig(c *gin.Context) { + c.JSON(http.StatusOK, s.cfgMgr.Get()) +} + +func (s *HookServer) handleSetConfig(c *gin.Context) { + var cfg Config + if err := c.ShouldBindJSON(&cfg); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + s.cfgMgr.Update(&cfg) + + // Save to file + if err := s.cfgMgr.Save(); err != nil { + log.Printf("Warning: failed to save config to file: %v", err) + } + + c.JSON(http.StatusOK, gin.H{"message": "configuration updated"}) +} + +func (s *HookServer) handleGetLogs(c *gin.Context) { + logs := s.GetLogs() + c.JSON(http.StatusOK, gin.H{ + "count": len(logs), + "logs": logs, + }) +} + +func (s *HookServer) handleClearLogs(c *gin.Context) { + s.ClearLogs() + c.JSON(http.StatusOK, gin.H{"message": "logs cleared"}) +} + +func (s *HookServer) handleStatus(c *gin.Context) { + cfg := s.cfgMgr.Get() + c.JSON(http.StatusOK, gin.H{ + "status": "running", + "port": s.serverCfg.Port, + "auth_enabled": s.serverCfg.Token != "", + "tls_enabled": s.serverCfg.TLSEnabled, + "mtls_enabled": s.serverCfg.TLSEnabled && s.serverCfg.CAFile != "", + "config_file": s.cfgMgr.ConfigPath(), + "pii_enabled": cfg.PII != nil && cfg.PII.Enabled, + "ab_enabled": cfg.ABTesting != nil && cfg.ABTesting.Enabled, + "log_count": len(s.GetLogs()), + }) +} + +func (s *HookServer) handleFetchRegistryTools(c *gin.Context) { + cfg := s.cfgMgr.Get() + if cfg.ABTesting == nil || cfg.ABTesting.ToolRegistry == nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "tool registry not configured"}) + return + } + + result, err := FetchToolsFromRegistry(cfg.ABTesting.ToolRegistry) + if err != nil { + c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, result) +} + +func (s *HookServer) handleGetABStats(c *gin.Context) { + c.JSON(http.StatusOK, s.abMgr.GetStats()) +} + +func (s *HookServer) handleResetABStats(c *gin.Context) { + s.abMgr.ResetStats() + c.JSON(http.StatusOK, gin.H{"message": "A/B testing stats and assignments reset"}) +} + +func (s *HookServer) handleSaveConfig(c *gin.Context) { + if err := s.cfgMgr.Save(); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "configuration saved to " + s.cfgMgr.ConfigPath()}) +} + +func (s *HookServer) handleTestPII(c *gin.Context) { + var body struct { + Text string `json:"text"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + cfg := s.cfgMgr.Get() + detector := NewPIIDetector(cfg.PII) + + data := map[string]interface{}{"text": body.Text} + result := detector.ScanAndSummarize(data) + redacted := detector.RedactString(body.Text) + + c.JSON(http.StatusOK, gin.H{ + "original": body.Text, + "redacted": redacted, + "scan": result, + }) +} + +// ============================================================================= +// Helper Functions +// ============================================================================= + +func (s *HookServer) validateAuth(c *gin.Context) bool { + if s.serverCfg.Token == "" { + return true + } + + auth := c.GetHeader("Authorization") + expected := "Bearer " + s.serverCfg.Token + if auth != expected { + c.JSON(http.StatusUnauthorized, server.ErrorResponse{ + Error: strPtr("invalid or missing bearer token"), + Code: responseCodePtr(server.CHECKFAILED), + }) + return false + } + return true +} + +func matchesPattern(pattern, value string) bool { + if pattern == "" { + return true + } + if strings.HasPrefix(pattern, "~") { + re, err := regexp.Compile(pattern[1:]) + if err != nil { + return false + } + return re.MatchString(value) + } + if strings.Contains(pattern, "*") { + regexPattern := "^" + strings.ReplaceAll(regexp.QuoteMeta(pattern), "\\*", ".*") + "$" + re, err := regexp.Compile(regexPattern) + if err != nil { + return false + } + return re.MatchString(value) + } + return pattern == value +} + +// matchesGlob is like matchesPattern but treats empty as non-matching +// (used for A/B test experiment matching where both toolkit and tool must be specified). +func matchesGlob(pattern, value string) bool { + if pattern == "" { + return true + } + return matchesPattern(pattern, value) +} + +func matchesInputs(expr string, inputs map[string]interface{}) bool { + if strings.Contains(expr, " contains ") { + parts := strings.SplitN(expr, " contains ", 2) + if len(parts) != 2 { + return false + } + key, substring := strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1]) + if val, ok := inputs[key]; ok { + return strings.Contains(fmt.Sprintf("%v", val), substring) + } + return false + } + if strings.Contains(expr, "=") { + parts := strings.SplitN(expr, "=", 2) + if len(parts) != 2 { + return false + } + key, expected := strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1]) + if val, ok := inputs[key]; ok { + return fmt.Sprintf("%v", val) == expected + } + return false + } + _, ok := inputs[expr] + return ok +} + +func actionToCode(action string) server.ResponseCode { + switch action { + case "proceed", "allow", "": + return server.OK + case "block", "deny": + return server.CHECKFAILED + case "rate_limit": + return server.RATELIMITEXCEEDED + default: + return server.OK + } +} + +func strPtr(s string) *string { + return &s +} + +func responseCodePtr(c server.ResponseCode) *server.ResponseCode { + return &c +} + +// joinRuleMatch combines a rule match string with a PII match string. +func joinRuleMatch(ruleMatch, piiMatch string) string { + if ruleMatch == "" { + return piiMatch + } + return ruleMatch + "+" + piiMatch +} + +// ============================================================================= +// Config File Watching +// ============================================================================= + +func watchConfigFile(path string, cfgMgr *ConfigManager, done <-chan struct{}) { + watcher, err := fsnotify.NewWatcher() + if err != nil { + log.Printf("Failed to create file watcher: %v", err) + return + } + defer watcher.Close() + + if err := watcher.Add(path); err != nil { + log.Printf("Failed to watch config file: %v", err) + return + } + + for { + select { + case event, ok := <-watcher.Events: + if !ok { + return + } + if event.Op&fsnotify.Write == fsnotify.Write { + log.Printf("Config file changed, reloading...") + if err := cfgMgr.Load(); err != nil { + log.Printf("Warning: failed to reload config: %v", err) + } else { + log.Printf("Configuration reloaded successfully") + } + } + case err, ok := <-watcher.Errors: + if !ok { + return + } + log.Printf("Watcher error: %v", err) + case <-done: + return + } + } +} + +// ============================================================================= +// TLS Configuration +// ============================================================================= + +func buildTLSConfig(cfg *ServerConfig) (*tls.Config, error) { + tlsConfig := &tls.Config{ + MinVersion: tls.VersionTLS12, + } + + if cfg.CAFile != "" { + caCert, err := os.ReadFile(cfg.CAFile) + if err != nil { + return nil, fmt.Errorf("failed to read CA certificate: %w", err) + } + + caCertPool := x509.NewCertPool() + if !caCertPool.AppendCertsFromPEM(caCert) { + return nil, errors.New("failed to parse CA certificate") + } + + tlsConfig.ClientCAs = caCertPool + tlsConfig.ClientAuth = tls.RequireAndVerifyClientCert + } + + return tlsConfig, nil +} + +// ============================================================================= +// Banner +// ============================================================================= + +func printBanner(cfg *ServerConfig) { + protocol := "http" + if cfg.TLSEnabled { + protocol = "https" + } + + fmt.Println(strings.Repeat("=", 60)) + fmt.Println("Hook Server (Advanced)") + fmt.Println(strings.Repeat("=", 60)) + fmt.Printf(" Port: %d\n", cfg.Port) + fmt.Printf(" Auth: %s\n", authStatus(cfg.Token)) + fmt.Printf(" TLS: %s\n", tlsStatus(cfg)) + fmt.Printf(" Config: %s\n", configStatus(cfg.ConfigFile)) + fmt.Println(strings.Repeat("-", 60)) + fmt.Println("Dashboard:") + fmt.Printf(" %s://localhost:%d/\n", protocol, cfg.Port) + fmt.Println() + fmt.Println("Webhook Endpoints:") + fmt.Printf(" GET %s://localhost:%d/health\n", protocol, cfg.Port) + fmt.Printf(" POST %s://localhost:%d/access\n", protocol, cfg.Port) + fmt.Printf(" POST %s://localhost:%d/pre\n", protocol, cfg.Port) + fmt.Printf(" POST %s://localhost:%d/post\n", protocol, cfg.Port) + fmt.Println() + fmt.Println("API Endpoints:") + fmt.Printf(" GET/PUT %s://localhost:%d/api/config\n", protocol, cfg.Port) + fmt.Printf(" GET/DEL %s://localhost:%d/api/logs\n", protocol, cfg.Port) + fmt.Printf(" GET %s://localhost:%d/api/status\n", protocol, cfg.Port) + fmt.Printf(" POST %s://localhost:%d/api/registry/fetch\n", protocol, cfg.Port) + fmt.Printf(" GET %s://localhost:%d/api/ab/stats\n", protocol, cfg.Port) + fmt.Printf(" POST %s://localhost:%d/api/pii/test\n", protocol, cfg.Port) + fmt.Println(strings.Repeat("=", 60)) + fmt.Println("Ready to receive requests...") + fmt.Println() +} + +func authStatus(token string) string { + if token == "" { + return "disabled" + } + return fmt.Sprintf("enabled (token: %s...)", token[:min(8, len(token))]) +} + +func tlsStatus(cfg *ServerConfig) string { + if !cfg.TLSEnabled { + return "disabled (HTTP)" + } + if cfg.CAFile != "" { + return "mTLS enabled (client cert required)" + } + return "TLS enabled (HTTPS)" +} + +func configStatus(path string) string { + if path == "" { + return "none (using defaults)" + } + return path + " (hot-reload enabled)" +} + +// ============================================================================= +// Main +// ============================================================================= + +func main() { + serverCfg := &ServerConfig{} + defaultConfigPath := "hook-config.yaml" + + flag.IntVar(&serverCfg.Port, "port", 8888, "Port to listen on") + flag.StringVar(&serverCfg.Token, "token", "", "Bearer token for authentication (empty = no auth)") + flag.BoolVar(&serverCfg.Verbose, "verbose", true, "Log all requests to stdout") + flag.StringVar(&serverCfg.ConfigFile, "config", "", "Path to YAML configuration file") + + // TLS flags + flag.BoolVar(&serverCfg.TLSEnabled, "tls", false, "Enable TLS/HTTPS") + flag.StringVar(&serverCfg.CertFile, "cert", "", "Path to server certificate (PEM)") + flag.StringVar(&serverCfg.KeyFile, "key", "", "Path to server private key (PEM)") + flag.StringVar(&serverCfg.CAFile, "ca", "", "Path to CA certificate for client verification (mTLS)") + flag.Parse() + + if serverCfg.TLSEnabled && (serverCfg.CertFile == "" || serverCfg.KeyFile == "") { + log.Fatal("TLS enabled but -cert and -key are required") + } + + // Determine config file path + configPath := serverCfg.ConfigFile + if configPath == "" { + configPath = defaultConfigPath + } + + cfgMgr := NewConfigManager(configPath) + + // Try to load existing config + if serverCfg.ConfigFile != "" { + if err := cfgMgr.Load(); err != nil { + log.Printf("Warning: failed to load config from %s: %v", configPath, err) + } else { + log.Printf("Loaded configuration from %s", configPath) + } + } else if _, err := os.Stat(defaultConfigPath); err == nil { + if err := cfgMgr.Load(); err != nil { + log.Printf("Warning: failed to load default config: %v", err) + } else { + log.Printf("Loaded configuration from %s", defaultConfigPath) + } + } else { + // Save default config to file + if err := cfgMgr.Save(); err != nil { + log.Printf("Warning: failed to save default config: %v", err) + } else { + log.Printf("Created default configuration at %s", configPath) + } + } + + // Watch config file for changes (stops when done channel is closed) + done := make(chan struct{}) + go watchConfigFile(configPath, cfgMgr, done) + + srv := NewHookServer(serverCfg, cfgMgr) + + gin.SetMode(gin.ReleaseMode) + router := gin.New() + router.Use(gin.Recovery()) + + // Dashboard UI + router.GET("/", srv.serveDashboard) + + // Webhook endpoints (from generated ServerInterface) + server.RegisterHandlers(router, srv) + + // API endpoints + api := router.Group("/api") + { + api.GET("/config", srv.handleGetConfig) + api.PUT("/config", srv.handleSetConfig) + api.POST("/config", srv.handleSetConfig) + api.POST("/config/save", srv.handleSaveConfig) + + api.GET("/logs", srv.handleGetLogs) + api.DELETE("/logs", srv.handleClearLogs) + + api.GET("/status", srv.handleStatus) + + api.POST("/registry/fetch", srv.handleFetchRegistryTools) + + api.GET("/ab/stats", srv.handleGetABStats) + api.DELETE("/ab/stats", srv.handleResetABStats) + + api.POST("/pii/test", srv.handleTestPII) + } + + printBanner(serverCfg) + + addr := fmt.Sprintf(":%d", serverCfg.Port) + + httpServer := &http.Server{ + Addr: addr, + Handler: router, + } + + if serverCfg.TLSEnabled { + tlsConfig, err := buildTLSConfig(serverCfg) + if err != nil { + log.Fatal("Failed to configure TLS:", err) + } + httpServer.TLSConfig = tlsConfig + } + + // Start server in background + go func() { + var err error + if serverCfg.TLSEnabled { + err = httpServer.ListenAndServeTLS(serverCfg.CertFile, serverCfg.KeyFile) + } else { + err = httpServer.ListenAndServe() + } + if err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Fatal("Server error:", err) + } + }() + + // Wait for interrupt signal + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + sig := <-quit + log.Printf("Received %s, shutting down gracefully...", sig) + + // Stop the config file watcher + close(done) + + // Give active requests up to 5 seconds to finish + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if err := httpServer.Shutdown(ctx); err != nil { + log.Printf("Forced shutdown: %v", err) + } else { + log.Println("Server stopped cleanly") + } +} + +// ensureConfigFile creates a config file with provided config if it doesn't exist. +func ensureConfigFile(path string, cfg *Config) error { + if _, err := os.Stat(path); err == nil { + return nil // File exists + } + + data, err := yaml.Marshal(cfg) + if err != nil { + return err + } + + return os.WriteFile(path, data, 0o644) +} diff --git a/examples/advanced_server/pii.go b/examples/advanced_server/pii.go new file mode 100644 index 0000000..b15090a --- /dev/null +++ b/examples/advanced_server/pii.go @@ -0,0 +1,210 @@ +package main + +import ( + "fmt" + "regexp" + "strings" +) + +// ============================================================================= +// PII Detection and Redaction +// ============================================================================= + +// PIIDetector detects and redacts personally identifiable information from text. +type PIIDetector struct { + patterns map[string]*regexp.Regexp + labels map[string]string // pattern name -> redacted label +} + +// NewPIIDetector creates a PIIDetector with patterns based on the provided PIIConfig. +func NewPIIDetector(cfg *PIIConfig) *PIIDetector { + d := &PIIDetector{ + patterns: make(map[string]*regexp.Regexp), + labels: make(map[string]string), + } + + if cfg == nil { + return d + } + + if cfg.Types.Email { + d.patterns["email"] = regexp.MustCompile(`[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}`) + d.labels["email"] = "[EMAIL REDACTED]" + } + if cfg.Types.IPv4 { + d.patterns["ipv4"] = regexp.MustCompile(`\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b`) + d.labels["ipv4"] = "[IP REDACTED]" + } + if cfg.Types.SSN { + d.patterns["ssn"] = regexp.MustCompile(`\b\d{3}-\d{2}-\d{4}\b`) + d.labels["ssn"] = "[SSN REDACTED]" + } + if cfg.Types.Phone { + d.patterns["phone"] = regexp.MustCompile(`\b(?:\+?1[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}\b`) + d.labels["phone"] = "[PHONE REDACTED]" + } + if cfg.Types.CreditCard { + d.patterns["credit_card"] = regexp.MustCompile(`\b\d{4}[-\s]?\d{4}[-\s]?\d{4}[-\s]?\d{4}\b`) + d.labels["credit_card"] = "[CREDIT CARD REDACTED]" + } + if cfg.Types.DateOfBirth { + d.patterns["date_of_birth"] = regexp.MustCompile(`\b(?:\d{1,2}[/\-]\d{1,2}[/\-]\d{2,4}|\d{4}[/\-]\d{1,2}[/\-]\d{1,2})\b`) + d.labels["date_of_birth"] = "[DOB REDACTED]" + } + + // Add custom patterns + for _, custom := range cfg.Custom { + re, err := regexp.Compile(custom.Pattern) + if err != nil { + continue + } + replacement := custom.Replacement + if replacement == "" { + replacement = fmt.Sprintf("[%s REDACTED]", strings.ToUpper(custom.Name)) + } + d.patterns[custom.Name] = re + d.labels[custom.Name] = replacement + } + + return d +} + +// DetectPII checks if a string contains any PII and returns the types found. +func (d *PIIDetector) DetectPII(text string) []PIIMatch { + var matches []PIIMatch + + for name, pattern := range d.patterns { + locs := pattern.FindAllStringIndex(text, -1) + for _, loc := range locs { + matches = append(matches, PIIMatch{ + Type: name, + Value: text[loc[0]:loc[1]], + Start: loc[0], + End: loc[1], + }) + } + } + + return matches +} + +// ContainsPII returns true if the text contains any PII. +func (d *PIIDetector) ContainsPII(text string) bool { + for _, pattern := range d.patterns { + if pattern.MatchString(text) { + return true + } + } + return false +} + +// RedactString replaces all PII in a string with redaction labels. +func (d *PIIDetector) RedactString(text string) string { + result := text + for name, pattern := range d.patterns { + result = pattern.ReplaceAllString(result, d.labels[name]) + } + return result +} + +// RedactMap recursively redacts PII from all string values in a map. +func (d *PIIDetector) RedactMap(data map[string]interface{}) map[string]interface{} { + if data == nil { + return nil + } + + result := make(map[string]interface{}, len(data)) + for key, val := range data { + result[key] = d.redactValue(val) + } + return result +} + +// ScanMap checks all string values in a map for PII and returns all matches found. +func (d *PIIDetector) ScanMap(data map[string]interface{}) []PIIMatch { + var allMatches []PIIMatch + d.scanValue(data, "", &allMatches) + return allMatches +} + +func (d *PIIDetector) redactValue(val interface{}) interface{} { + switch v := val.(type) { + case string: + return d.RedactString(v) + case map[string]interface{}: + return d.RedactMap(v) + case []interface{}: + result := make([]interface{}, len(v)) + for i, item := range v { + result[i] = d.redactValue(item) + } + return result + default: + return val + } +} + +func (d *PIIDetector) scanValue(val interface{}, path string, matches *[]PIIMatch) { + switch v := val.(type) { + case string: + found := d.DetectPII(v) + for i := range found { + found[i].Path = path + } + *matches = append(*matches, found...) + case map[string]interface{}: + for key, item := range v { + newPath := key + if path != "" { + newPath = path + "." + key + } + d.scanValue(item, newPath, matches) + } + case []interface{}: + for i, item := range v { + newPath := fmt.Sprintf("%s[%d]", path, i) + d.scanValue(item, newPath, matches) + } + } +} + +// PIIMatch represents a single PII detection match. +type PIIMatch struct { + Type string `json:"type"` + Value string `json:"value"` + Start int `json:"start"` + End int `json:"end"` + Path string `json:"path,omitempty"` // JSON path where PII was found +} + +// PIIScanResult is the result of scanning content for PII. +type PIIScanResult struct { + ContainsPII bool `json:"contains_pii"` + Matches []PIIMatch `json:"matches"` + TypeCounts map[string]int `json:"type_counts"` +} + +// ScanAndSummarize scans a map for PII and returns a summary. +func (d *PIIDetector) ScanAndSummarize(data map[string]interface{}) PIIScanResult { + return d.ScanAndSummarizeAny(data) +} + +// ScanAndSummarizeAny scans any value (string, map, slice, etc.) for PII and returns a summary. +func (d *PIIDetector) ScanAndSummarizeAny(data interface{}) PIIScanResult { + var matches []PIIMatch + d.scanValue(data, "", &matches) + counts := make(map[string]int) + for _, m := range matches { + counts[m.Type]++ + } + return PIIScanResult{ + ContainsPII: len(matches) > 0, + Matches: matches, + TypeCounts: counts, + } +} + +// RedactAny recursively redacts PII from any value (string, map, slice, etc.). +func (d *PIIDetector) RedactAny(data interface{}) interface{} { + return d.redactValue(data) +} diff --git a/examples/advanced_server/ui.go b/examples/advanced_server/ui.go new file mode 100644 index 0000000..e73dae9 --- /dev/null +++ b/examples/advanced_server/ui.go @@ -0,0 +1,1122 @@ +package main + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +// serveDashboard serves the web dashboard UI. +func (s *HookServer) serveDashboard(c *gin.Context) { + c.Header("Content-Type", "text/html; charset=utf-8") + c.String(http.StatusOK, dashboardHTML) +} + +const dashboardHTML = ` + + + + +Hook Server Dashboard + + + + + + +
+ +
+

Access & Execution Rules

+ + +
+
+

Access Control

+
+ + +
+
+
+
+ + +
+
+

Pre-Execution Rules

+
+ + +
+
+
+
+ + +
+
+

Post-Execution Rules

+
+ + +
+
+
+
+ + +
+ + +
+

PII Redaction

+ +
+
+

Settings

+ +
+ + + + +

PII Types to Detect

+
+ Email Addresses + +
+
+ IPv4 Addresses + +
+
+ Social Security Numbers (SSN) + +
+
+ Phone Numbers + +
+
+ Credit Card Numbers + +
+
+ Dates of Birth + +
+
+ + +
+

Test PII Detection

+ + +
+
+
+ + +
+

A/B & Canary Testing

+ +
+
+

Settings

+ +
+ +

Tool Registry

+
+ + +
+
+ + +
+ +
+
+ + +
+
+

Experiments

+ +
+
+
+ + +
+
+

Statistics

+
+ + +
+
+
+
+
+ + +
+

Request Logs

+
+
+

Recent Requests

+
+ + +
+
+
+
+
+ + +
+

Raw Configuration

+ +
+
+

Server Status

+ +
+
+
+ +
+
+

YAML Configuration

+
+ + +
+
+ +
+
+
+ + + +` diff --git a/examples/basic_rules/Dockerfile b/examples/basic_rules/Dockerfile new file mode 100644 index 0000000..536d04b --- /dev/null +++ b/examples/basic_rules/Dockerfile @@ -0,0 +1,25 @@ +FROM --platform=$BUILDPLATFORM golang:1.25-alpine AS builder + +ARG TARGETOS +ARG TARGETARCH + +WORKDIR /src + +# Cache dependency downloads +COPY go.mod go.sum ./ +RUN go mod download + +# Copy shared package and example source +COPY pkg/ pkg/ +COPY examples/basic_rules/ examples/basic_rules/ + +RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \ + go build -ldflags="-s -w" -trimpath -o /bin/server ./examples/basic_rules + +FROM gcr.io/distroless/static-debian12 + +COPY --from=builder /bin/server /bin/server + +EXPOSE 8080 + +ENTRYPOINT ["/bin/server"] diff --git a/examples/basic_rules/main.go b/examples/basic_rules/main.go index c920e81..981c47c 100644 --- a/examples/basic_rules/main.go +++ b/examples/basic_rules/main.go @@ -111,15 +111,6 @@ type PreRule struct { type PreOverrideConfig struct { Inputs map[string]interface{} `yaml:"inputs" json:"inputs"` Secrets map[string]string `yaml:"secrets" json:"secrets"` - Headers map[string]string `yaml:"headers" json:"headers"` - Server *ServerOverride `yaml:"server" json:"server"` -} - -// ServerOverride defines server routing override. -type ServerOverride struct { - Name string `yaml:"name" json:"name"` - URI string `yaml:"uri" json:"uri"` - Type string `yaml:"type" json:"type"` // arcade or mcp } // PostConfig controls post-execution hook behavior. @@ -314,7 +305,7 @@ func (ts *TestServer) AccessHook(c *gin.Context) { if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, server.ErrorResponse{ Error: strPtr("invalid request body: " + err.Error()), - Code: strPtr("INVALID_REQUEST"), + Code: responseCodePtr(server.CHECKFAILED), }) return } @@ -389,7 +380,7 @@ func (ts *TestServer) PreHook(c *gin.Context) { if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, server.ErrorResponse{ Error: strPtr("invalid request body: " + err.Error()), - Code: strPtr("INVALID_REQUEST"), + Code: responseCodePtr(server.CHECKFAILED), }) return } @@ -455,20 +446,10 @@ func (ts *TestServer) applyPreRule(rule PreRule) *server.PreHookResult { if len(rule.Override.Inputs) > 0 { override.Inputs = &rule.Override.Inputs } - if len(rule.Override.Headers) > 0 { - override.Headers = &rule.Override.Headers - } if len(rule.Override.Secrets) > 0 { secrets := []map[string]string{rule.Override.Secrets} override.Secrets = &secrets } - if rule.Override.Server != nil { - override.Server = &server.ServerInfo{ - Name: rule.Override.Server.Name, - Uri: rule.Override.Server.URI, - Type: server.ServerInfoType(rule.Override.Server.Type), - } - } result.Override = override } @@ -486,7 +467,7 @@ func (ts *TestServer) PostHook(c *gin.Context) { if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, server.ErrorResponse{ Error: strPtr("invalid request body: " + err.Error()), - Code: strPtr("INVALID_REQUEST"), + Code: responseCodePtr(server.CHECKFAILED), }) return } @@ -533,8 +514,12 @@ func (ts *TestServer) matchPostRule(rule PostRule, userID string, req server.Pos if rule.Success != nil && req.Success != nil && *rule.Success != *req.Success { return false } - if rule.OutputMatch != "" && !ts.matchesOutput(rule.OutputMatch, req.Output) { - return false + if rule.OutputMatch != "" { + if outputMap, ok := req.Output.(map[string]interface{}); ok { + if !ts.matchesOutput(rule.OutputMatch, outputMap) { + return false + } + } } return true } @@ -550,8 +535,9 @@ func (ts *TestServer) applyPostRule(rule PostRule) *server.PostHookResult { if rule.Override != nil && rule.Action == "proceed" { if len(rule.Override.Output) > 0 { + output := map[string]interface{}(rule.Override.Output) result.Override = &server.PostHookOverride{ - Output: &rule.Override.Output, + Output: output, } } } @@ -573,7 +559,7 @@ func (ts *TestServer) validateAuth(c *gin.Context) bool { if auth != expected { c.JSON(http.StatusUnauthorized, server.ErrorResponse{ Error: strPtr("invalid or missing bearer token"), - Code: strPtr("UNAUTHORIZED"), + Code: responseCodePtr(server.CHECKFAILED), }) return false } @@ -654,6 +640,10 @@ func strPtr(s string) *string { return &s } +func responseCodePtr(c server.ResponseCode) *server.ResponseCode { + return &c +} + // ============================================================================= // Debug/Admin Endpoints // ============================================================================= diff --git a/examples/content_filter/Dockerfile b/examples/content_filter/Dockerfile new file mode 100644 index 0000000..40a20e0 --- /dev/null +++ b/examples/content_filter/Dockerfile @@ -0,0 +1,25 @@ +FROM --platform=$BUILDPLATFORM golang:1.25-alpine AS builder + +ARG TARGETOS +ARG TARGETARCH + +WORKDIR /src + +# Cache dependency downloads +COPY go.mod go.sum ./ +RUN go mod download + +# Copy shared package and example source +COPY pkg/ pkg/ +COPY examples/content_filter/ examples/content_filter/ + +RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \ + go build -ldflags="-s -w" -trimpath -o /bin/server ./examples/content_filter + +FROM gcr.io/distroless/static-debian12 + +COPY --from=builder /bin/server /bin/server + +EXPOSE 8080 + +ENTRYPOINT ["/bin/server"] diff --git a/examples/content_filter/README.md b/examples/content_filter/README.md new file mode 100644 index 0000000..3b9829c --- /dev/null +++ b/examples/content_filter/README.md @@ -0,0 +1,100 @@ +# Content Filter Example + +A minimal hook server that demonstrates how to **filter tool calls and responses based on their content**. + +## What It Shows + +- **Access hook**: Block entire toolkits from being visible +- **Pre-execution hook**: Block tool execution when inputs contain prohibited content (keywords or regex patterns) +- **Post-execution hook**: Block or replace prohibited content in tool outputs + +## Quick Start + +```bash +# Run with a config file +go run ./examples/content_filter -config filter-rules.yaml +``` + +## Config File Format + +```yaml +# Simple keyword blocking (case-insensitive, checked in both inputs and outputs) +blocked_keywords: + - "confidential" + - "internal-only" + - "secret-project" + +# Block entire toolkits +blocked_toolkits: + - "DangerousToolkit" + - "Internal*" + +# Regex patterns for blocking inputs before execution +blocked_input_patterns: + - name: "external-email" + pattern: "@(?!mycompany\\.com)\\w+\\.\\w+" + action: block + message: "External email addresses are not allowed" + + - name: "sql-injection" + pattern: "(?i)(DROP|DELETE|TRUNCATE)\\s+TABLE" + action: block + message: "Potentially dangerous SQL detected" + +# Regex patterns for filtering outputs after execution +blocked_output_patterns: + - name: "internal-urls" + pattern: "https?://internal\\.[\\w.]+" + action: replace + replacement: "[INTERNAL URL REMOVED]" + + - name: "api-keys" + pattern: "(?i)(api[_-]?key|token)[\"']?\\s*[:=]\\s*[\"']?[\\w-]{20,}" + action: block + message: "Output contains what appears to be an API key" +``` + +## How It Works + +### Input Filtering (Pre-Hook) +1. All tool input values are flattened into a single string +2. Blocked keywords are checked (case-insensitive substring match) +3. Blocked input patterns are checked (regex match) +4. If any match is found, the tool execution is blocked with an error message + +### Output Filtering (Post-Hook) +1. All tool output values are flattened into a single string +2. Blocked keywords are checked +3. Blocked output patterns are checked: + - `action: "block"` - Reject the entire response + - `action: "replace"` - Replace matching content with the replacement string + +## Testing + +```bash +# Start the server with example rules +go run ./examples/content_filter -config filter-rules.yaml & + +# Test pre-hook - should be blocked (contains blocked keyword) +curl -X POST http://localhost:8888/pre \ + -H "Content-Type: application/json" \ + -d '{ + "execution_id": "test-1", + "tool": {"name": "Search", "toolkit": "Web", "version": "1.0.0"}, + "context": {"user_id": "user1"}, + "inputs": {"query": "find confidential documents"} + }' + +# Test post-hook - content replacement +curl -X POST http://localhost:8888/post \ + -H "Content-Type: application/json" \ + -d '{ + "execution_id": "test-2", + "tool": {"name": "Search", "toolkit": "Web", "version": "1.0.0"}, + "context": {"user_id": "user1"}, + "server": {"name": "s1", "uri": "http://localhost", "type": "arcade"}, + "inputs": {"query": "test"}, + "output": {"result": "Visit https://internal.company.com/secret for details"}, + "success": true + }' +``` diff --git a/examples/content_filter/example-config.yaml b/examples/content_filter/example-config.yaml new file mode 100644 index 0000000..34161cc --- /dev/null +++ b/examples/content_filter/example-config.yaml @@ -0,0 +1,37 @@ +# Content Filter Configuration +# +# Demonstrates keyword and pattern-based content filtering. + +# Simple keyword blocking (case-insensitive, checked in both inputs and outputs) +blocked_keywords: + - "confidential" + - "internal-only" + - "top-secret" + +# Block entire toolkits from being visible +blocked_toolkits: + - "DangerousToolkit" + +# Regex patterns for blocking tool inputs before execution +blocked_input_patterns: + - name: "sql-injection" + pattern: "(?i)(DROP|DELETE|TRUNCATE)\\s+TABLE" + action: block + message: "Potentially dangerous SQL detected in input" + + - name: "blocked-domain" + pattern: "@blocked\\.com" + action: block + message: "References to blocked.com are not allowed" + +# Regex patterns for filtering tool outputs after execution +blocked_output_patterns: + - name: "internal-urls" + pattern: "https?://internal\\.[\\w.]+" + action: replace + replacement: "[INTERNAL URL REMOVED]" + + - name: "api-keys" + pattern: "(?i)(api[_-]?key|token)[\"']?\\s*[:=]\\s*[\"']?[\\w-]{20,}" + action: block + message: "Output contains what appears to be an API key or token" diff --git a/examples/content_filter/main.go b/examples/content_filter/main.go new file mode 100644 index 0000000..82ab45a --- /dev/null +++ b/examples/content_filter/main.go @@ -0,0 +1,412 @@ +// content_filter demonstrates how to block or modify tool calls based on their content. +// +// This minimal hook server shows: +// - Blocking tool execution based on input content (pre-hook) +// - Blocking or replacing tool output based on content (post-hook) +// - Using keyword lists and pattern matching for content filtering +// +// Usage: +// +// go run ./examples/content_filter -port 8888 +// go run ./examples/content_filter -port 8888 -config filter-rules.yaml +package main + +import ( + "flag" + "fmt" + "log" + "net/http" + "os" + "regexp" + "strings" + + "github.com/gin-gonic/gin" + "gopkg.in/yaml.v3" + + "github.com/ArcadeAI/logical-extensions-examples/pkg/server" +) + +// Config defines content filtering rules. +type Config struct { + // BlockedKeywords are blocked in both inputs and outputs + BlockedKeywords []string `yaml:"blocked_keywords" json:"blocked_keywords"` + + // BlockedInputPatterns are regex patterns that block tool execution when found in inputs + BlockedInputPatterns []PatternRule `yaml:"blocked_input_patterns" json:"blocked_input_patterns"` + + // BlockedOutputPatterns are regex patterns that block or replace content in outputs + BlockedOutputPatterns []PatternRule `yaml:"blocked_output_patterns" json:"blocked_output_patterns"` + + // BlockedToolkits are toolkit names that are entirely blocked + BlockedToolkits []string `yaml:"blocked_toolkits" json:"blocked_toolkits"` +} + +// PatternRule defines a regex pattern with an action. +type PatternRule struct { + Name string `yaml:"name" json:"name"` + Pattern string `yaml:"pattern" json:"pattern"` + Action string `yaml:"action" json:"action"` // "block" or "replace" + Replacement string `yaml:"replacement" json:"replacement"` + Message string `yaml:"message" json:"message"` // error message when blocking +} + +// FilterServer implements the webhook ServerInterface. +type FilterServer struct { + config *Config + token string + compiledInputs []*compiledPattern + compiledOutputs []*compiledPattern +} + +type compiledPattern struct { + rule PatternRule + pattern *regexp.Regexp +} + +func NewFilterServer(cfg *Config, token string) *FilterServer { + s := &FilterServer{config: cfg, token: token} + s.compilePatterns() + return s +} + +func (s *FilterServer) compilePatterns() { + for _, rule := range s.config.BlockedInputPatterns { + re, err := regexp.Compile(rule.Pattern) + if err != nil { + log.Printf("Warning: invalid input pattern %q: %v", rule.Pattern, err) + continue + } + s.compiledInputs = append(s.compiledInputs, &compiledPattern{rule: rule, pattern: re}) + } + for _, rule := range s.config.BlockedOutputPatterns { + re, err := regexp.Compile(rule.Pattern) + if err != nil { + log.Printf("Warning: invalid output pattern %q: %v", rule.Pattern, err) + continue + } + s.compiledOutputs = append(s.compiledOutputs, &compiledPattern{rule: rule, pattern: re}) + } +} + +// HealthCheck implements ServerInterface. +func (s *FilterServer) HealthCheck(c *gin.Context) { + status := server.Healthy + c.JSON(http.StatusOK, server.HealthResponse{Status: &status}) +} + +// AccessHook implements ServerInterface. +// Filters out blocked toolkits. +func (s *FilterServer) AccessHook(c *gin.Context) { + if !s.validateAuth(c) { + return + } + + var req server.AccessHookRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, server.ErrorResponse{ + Error: strPtr("invalid request body: " + err.Error()), + Code: responseCodePtr(server.CHECKFAILED), + }) + return + } + + // Build deny list for blocked toolkits + deny := make(server.Toolkits) + for _, blocked := range s.config.BlockedToolkits { + for toolkitName, toolkitInfo := range req.Toolkits { + if matchGlob(blocked, toolkitName) { + deny[toolkitName] = toolkitInfo + log.Printf("[ACCESS] Blocked toolkit %q for user %q", toolkitName, req.UserId) + } + } + } + + result := &server.AccessHookResult{} + if len(deny) > 0 { + result.Deny = &deny + } + c.JSON(http.StatusOK, result) +} + +// PreHook implements ServerInterface. +// Checks tool inputs against content filtering rules. +func (s *FilterServer) PreHook(c *gin.Context) { + if !s.validateAuth(c) { + return + } + + var req server.PreHookRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, server.ErrorResponse{ + Error: strPtr("invalid request body: " + err.Error()), + Code: responseCodePtr(server.CHECKFAILED), + }) + return + } + + // Serialize all inputs to a single string for keyword/pattern checking + inputStr := flattenValue(req.Inputs) + + // Check blocked keywords in inputs + for _, keyword := range s.config.BlockedKeywords { + if strings.Contains(strings.ToLower(inputStr), strings.ToLower(keyword)) { + errMsg := fmt.Sprintf("Input contains blocked content: %q", keyword) + log.Printf("[PRE] Blocked: %s", errMsg) + c.JSON(http.StatusOK, server.PreHookResult{ + Code: server.CHECKFAILED, + ErrorMessage: &errMsg, + }) + return + } + } + + // Check regex patterns against inputs + for _, cp := range s.compiledInputs { + if cp.pattern.MatchString(inputStr) { + msg := cp.rule.Message + if msg == "" { + msg = fmt.Sprintf("Input matched blocked pattern: %s", cp.rule.Name) + } + log.Printf("[PRE] Blocked by pattern %q: %s", cp.rule.Name, msg) + c.JSON(http.StatusOK, server.PreHookResult{ + Code: server.CHECKFAILED, + ErrorMessage: &msg, + }) + return + } + } + + log.Printf("[PRE] Allowed %s.%s", req.Tool.Toolkit, req.Tool.Name) + c.JSON(http.StatusOK, server.PreHookResult{Code: server.OK}) +} + +// PostHook implements ServerInterface. +// Checks tool outputs against content filtering rules - can block or replace content. +func (s *FilterServer) PostHook(c *gin.Context) { + if !s.validateAuth(c) { + return + } + + var req server.PostHookRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, server.ErrorResponse{ + Error: strPtr("invalid request body: " + err.Error()), + Code: responseCodePtr(server.CHECKFAILED), + }) + return + } + + outputStr := flattenValue(req.Output) + + // Check blocked keywords in output + for _, keyword := range s.config.BlockedKeywords { + if strings.Contains(strings.ToLower(outputStr), strings.ToLower(keyword)) { + errMsg := fmt.Sprintf("Output contains blocked content: %q", keyword) + log.Printf("[POST] Blocked: %s", errMsg) + c.JSON(http.StatusOK, server.PostHookResult{ + Code: server.CHECKFAILED, + ErrorMessage: &errMsg, + }) + return + } + } + + // Check regex patterns against output - support block and replace actions + modified := false + result := copyValue(req.Output) + for _, cp := range s.compiledOutputs { + if cp.pattern.MatchString(outputStr) { + if cp.rule.Action == "block" { + msg := cp.rule.Message + if msg == "" { + msg = fmt.Sprintf("Output matched blocked pattern: %s", cp.rule.Name) + } + log.Printf("[POST] Blocked by pattern %q", cp.rule.Name) + c.JSON(http.StatusOK, server.PostHookResult{ + Code: server.CHECKFAILED, + ErrorMessage: &msg, + }) + return + } + if cp.rule.Action == "replace" { + // Replace matching content in all string values + result = replaceInValue(result, cp.pattern, cp.rule.Replacement) + modified = true + log.Printf("[POST] Replaced content matching pattern %q", cp.rule.Name) + } + } + } + + if modified { + c.JSON(http.StatusOK, server.PostHookResult{ + Code: server.OK, + Override: &server.PostHookOverride{Output: result}, + }) + return + } + + c.JSON(http.StatusOK, server.PostHookResult{Code: server.OK}) +} + +// ============================================================================= +// Helper Functions +// ============================================================================= + +func (s *FilterServer) validateAuth(c *gin.Context) bool { + if s.token == "" { + return true + } + auth := c.GetHeader("Authorization") + if auth != "Bearer "+s.token { + c.JSON(http.StatusUnauthorized, server.ErrorResponse{ + Error: strPtr("invalid or missing bearer token"), + Code: responseCodePtr(server.CHECKFAILED), + }) + return false + } + return true +} + +// flattenValue converts any value to a single string for content searching. +func flattenValue(v interface{}) string { + switch val := v.(type) { + case string: + return val + case map[string]interface{}: + var parts []string + for _, item := range val { + parts = append(parts, flattenValue(item)) + } + return strings.Join(parts, " ") + case []interface{}: + var parts []string + for _, item := range val { + parts = append(parts, flattenValue(item)) + } + return strings.Join(parts, " ") + default: + return fmt.Sprintf("%v", v) + } +} + +// copyValue creates a deep copy of any value. +func copyValue(v interface{}) interface{} { + switch val := v.(type) { + case map[string]interface{}: + result := make(map[string]interface{}, len(val)) + for k, item := range val { + result[k] = copyValue(item) + } + return result + case []interface{}: + result := make([]interface{}, len(val)) + for i, item := range val { + result[i] = copyValue(item) + } + return result + default: + return v + } +} + +// replaceInValue replaces regex matches in all string values recursively. +func replaceInValue(v interface{}, pattern *regexp.Regexp, replacement string) interface{} { + switch val := v.(type) { + case string: + return pattern.ReplaceAllString(val, replacement) + case map[string]interface{}: + result := make(map[string]interface{}, len(val)) + for k, item := range val { + result[k] = replaceInValue(item, pattern, replacement) + } + return result + case []interface{}: + result := make([]interface{}, len(val)) + for i, item := range val { + result[i] = replaceInValue(item, pattern, replacement) + } + return result + default: + return v + } +} + +// matchGlob matches a glob pattern against a value. +func matchGlob(pattern, value string) bool { + if pattern == "" || pattern == "*" { + return true + } + if strings.Contains(pattern, "*") { + regexPattern := "^" + strings.ReplaceAll(regexp.QuoteMeta(pattern), "\\*", ".*") + "$" + re, err := regexp.Compile(regexPattern) + if err != nil { + return false + } + return re.MatchString(value) + } + return pattern == value +} + +func strPtr(s string) *string { return &s } + +func responseCodePtr(c server.ResponseCode) *server.ResponseCode { return &c } + +// ============================================================================= +// Main +// ============================================================================= + +func main() { + var ( + port int + token string + configFile string + ) + + flag.IntVar(&port, "port", 8888, "Port to listen on") + flag.StringVar(&token, "token", "", "Bearer token for authentication") + flag.StringVar(&configFile, "config", "", "Path to YAML config file with filter rules") + flag.Parse() + + cfg := &Config{ + // Default example: block some keywords + BlockedKeywords: []string{}, + BlockedInputPatterns: []PatternRule{}, + BlockedOutputPatterns: []PatternRule{}, + BlockedToolkits: []string{}, + } + + if configFile != "" { + data, err := os.ReadFile(configFile) + if err != nil { + log.Fatalf("Failed to read config: %v", err) + } + if err := yaml.Unmarshal(data, cfg); err != nil { + log.Fatalf("Failed to parse config: %v", err) + } + log.Printf("Loaded filter config from %s", configFile) + } else { + log.Println("No config file specified. Use -config to load content filter rules.") + } + + log.Printf(" Blocked keywords: %d", len(cfg.BlockedKeywords)) + log.Printf(" Blocked input patterns: %d", len(cfg.BlockedInputPatterns)) + log.Printf(" Blocked output patterns: %d", len(cfg.BlockedOutputPatterns)) + log.Printf(" Blocked toolkits: %d", len(cfg.BlockedToolkits)) + + srv := NewFilterServer(cfg, token) + + gin.SetMode(gin.ReleaseMode) + router := gin.New() + router.Use(gin.Recovery()) + server.RegisterHandlers(router, srv) + + addr := fmt.Sprintf(":%d", port) + fmt.Printf("\nContent Filter Hook Server listening on %s\n", addr) + fmt.Printf(" POST /access - Filter out blocked toolkits\n") + fmt.Printf(" POST /pre - Block inputs with prohibited content\n") + fmt.Printf(" POST /post - Block or replace prohibited output content\n\n") + + if err := router.Run(addr); err != nil { + log.Fatal("Failed to start server:", err) + } +} diff --git a/examples/pii_redactor/Dockerfile b/examples/pii_redactor/Dockerfile new file mode 100644 index 0000000..608a18d --- /dev/null +++ b/examples/pii_redactor/Dockerfile @@ -0,0 +1,25 @@ +FROM --platform=$BUILDPLATFORM golang:1.25-alpine AS builder + +ARG TARGETOS +ARG TARGETARCH + +WORKDIR /src + +# Cache dependency downloads +COPY go.mod go.sum ./ +RUN go mod download + +# Copy shared package and example source +COPY pkg/ pkg/ +COPY examples/pii_redactor/ examples/pii_redactor/ + +RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \ + go build -ldflags="-s -w" -trimpath -o /bin/server ./examples/pii_redactor + +FROM gcr.io/distroless/static-debian12 + +COPY --from=builder /bin/server /bin/server + +EXPOSE 8080 + +ENTRYPOINT ["/bin/server"] diff --git a/examples/pii_redactor/README.md b/examples/pii_redactor/README.md new file mode 100644 index 0000000..7401976 --- /dev/null +++ b/examples/pii_redactor/README.md @@ -0,0 +1,83 @@ +# PII Redactor Example + +A minimal hook server that demonstrates how to **detect and redact personally identifiable information (PII)** from tool outputs. + +## What It Shows + +- **Post-execution hook**: Scans all string values in tool outputs for PII patterns +- **Redact mode**: Replaces detected PII with labeled placeholders +- **Block mode**: Rejects the entire response if PII is detected +- Recursive scanning of nested objects and arrays + +## Quick Start + +```bash +# Redact all PII types (default) +go run ./examples/pii_redactor + +# Only detect specific PII types +go run ./examples/pii_redactor -types "email,ssn,credit_card" + +# Block responses instead of redacting +go run ./examples/pii_redactor -action block +``` + +## Supported PII Types + +| Type | Flag | Example | Redacted As | +| -------------- | ---------------- | ------------------------ | ------------------------ | +| Email | `email` | `user@example.com` | `[EMAIL REDACTED]` | +| IPv4 Address | `ipv4` | `192.168.1.1` | `[IP REDACTED]` | +| SSN | `ssn` | `123-45-6789` | `[SSN REDACTED]` | +| Phone Number | `phone` | `(555) 123-4567` | `[PHONE REDACTED]` | +| Credit Card | `credit_card` | `4111-1111-1111-1111` | `[CREDIT CARD REDACTED]` | +| Date of Birth | `date_of_birth` | `01/15/1990` | `[DOB REDACTED]` | + +## How It Works + +1. The **access** and **pre-execution** hooks are pass-throughs (PII redaction only applies to outputs) +2. The **post-execution hook** receives the tool's output +3. All string values in the output are recursively scanned for PII patterns +4. Based on the configured action: + - **Redact**: Each PII match is replaced with a type-specific placeholder + - **Block**: The entire response is rejected with an error listing the PII types found + +## Testing + +```bash +# Start the server +go run ./examples/pii_redactor & + +# Test with PII in output - will be redacted +curl -X POST http://localhost:8888/post \ + -H "Content-Type: application/json" \ + -d '{ + "execution_id": "test-1", + "tool": {"name": "Lookup", "toolkit": "Database", "version": "1.0.0"}, + "context": {"user_id": "user1"}, + "server": {"name": "s1", "uri": "http://localhost", "type": "arcade"}, + "inputs": {"query": "get user info"}, + "output": { + "name": "Jane Smith", + "email": "jane@example.com", + "phone": "(555) 123-4567", + "ssn": "123-45-6789", + "ip": "Client connected from 10.0.1.42" + }, + "success": true + }' + +# Expected output: all PII fields are redacted +# { +# "code": "OK", +# "override": { +# "output": { +# "name": "Jane Smith", +# "email": "[EMAIL REDACTED]", +# "phone": "[PHONE REDACTED]", +# "ssn": "[SSN REDACTED]", +# "ip": "Client connected from [IP REDACTED]" +# } +# } +# } +``` diff --git a/examples/pii_redactor/main.go b/examples/pii_redactor/main.go new file mode 100644 index 0000000..3e03a2e --- /dev/null +++ b/examples/pii_redactor/main.go @@ -0,0 +1,341 @@ +// pii_redactor demonstrates how to detect and redact PII from tool outputs. +// +// This minimal hook server shows: +// - Scanning tool outputs for PII (emails, IPs, SSNs, phone numbers, etc.) +// - Replacing detected PII with labeled placeholders +// - Optionally blocking responses that contain PII instead of redacting +// +// Usage: +// +// go run ./examples/pii_redactor -port 8888 +// go run ./examples/pii_redactor -port 8888 -action block +// go run ./examples/pii_redactor -port 8888 -types "email,ssn,credit_card" +package main + +import ( + "flag" + "fmt" + "log" + "net/http" + "regexp" + "strings" + + "github.com/gin-gonic/gin" + + "github.com/ArcadeAI/logical-extensions-examples/pkg/server" +) + +// ============================================================================= +// PII Detection +// ============================================================================= + +// PIIPattern defines a PII type with its detection regex. +type PIIPattern struct { + Name string + Regex *regexp.Regexp + Replacement string +} + +// AllPIIPatterns returns all available PII detection patterns. +func AllPIIPatterns() map[string]PIIPattern { + return map[string]PIIPattern{ + "email": { + Name: "email", + Regex: regexp.MustCompile(`[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}`), + Replacement: "[EMAIL REDACTED]", + }, + "ipv4": { + Name: "ipv4", + Regex: regexp.MustCompile(`\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b`), + Replacement: "[IP REDACTED]", + }, + "ssn": { + Name: "ssn", + Regex: regexp.MustCompile(`\b\d{3}-\d{2}-\d{4}\b`), + Replacement: "[SSN REDACTED]", + }, + "phone": { + Name: "phone", + Regex: regexp.MustCompile(`\b(?:\+?1[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}\b`), + Replacement: "[PHONE REDACTED]", + }, + "credit_card": { + Name: "credit_card", + Regex: regexp.MustCompile(`\b\d{4}[-\s]?\d{4}[-\s]?\d{4}[-\s]?\d{4}\b`), + Replacement: "[CREDIT CARD REDACTED]", + }, + "date_of_birth": { + Name: "date_of_birth", + Regex: regexp.MustCompile(`\b(?:\d{1,2}[/\-]\d{1,2}[/\-]\d{2,4}|\d{4}[/\-]\d{1,2}[/\-]\d{1,2})\b`), + Replacement: "[DOB REDACTED]", + }, + } +} + +// ============================================================================= +// Server +// ============================================================================= + +// RedactorServer implements the webhook ServerInterface. +type RedactorServer struct { + token string + action string // "redact" or "block" + patterns []PIIPattern // active patterns +} + +func NewRedactorServer(token, action string, enabledTypes []string) *RedactorServer { + allPatterns := AllPIIPatterns() + var active []PIIPattern + + for _, t := range enabledTypes { + if p, ok := allPatterns[t]; ok { + active = append(active, p) + } else { + log.Printf("Warning: unknown PII type %q", t) + } + } + + return &RedactorServer{ + token: token, + action: action, + patterns: active, + } +} + +// HealthCheck implements ServerInterface. +func (s *RedactorServer) HealthCheck(c *gin.Context) { + status := server.Healthy + c.JSON(http.StatusOK, server.HealthResponse{Status: &status}) +} + +// AccessHook implements ServerInterface. Pass-through. +func (s *RedactorServer) AccessHook(c *gin.Context) { + if !s.validateAuth(c) { + return + } + var req server.AccessHookRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, server.ErrorResponse{ + Error: strPtr("invalid request body: " + err.Error()), + Code: responseCodePtr(server.CHECKFAILED), + }) + return + } + // Pass-through: PII redaction doesn't affect tool visibility + c.JSON(http.StatusOK, server.AccessHookResult{}) +} + +// PreHook implements ServerInterface. Pass-through. +func (s *RedactorServer) PreHook(c *gin.Context) { + if !s.validateAuth(c) { + return + } + var req server.PreHookRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, server.ErrorResponse{ + Error: strPtr("invalid request body: " + err.Error()), + Code: responseCodePtr(server.CHECKFAILED), + }) + return + } + // Pass-through: PII redaction only applies to outputs + c.JSON(http.StatusOK, server.PreHookResult{Code: server.OK}) +} + +// PostHook implements ServerInterface. +// This is where PII detection and redaction happens. +func (s *RedactorServer) PostHook(c *gin.Context) { + if !s.validateAuth(c) { + return + } + + var req server.PostHookRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, server.ErrorResponse{ + Error: strPtr("invalid request body: " + err.Error()), + Code: responseCodePtr(server.CHECKFAILED), + }) + return + } + + // Scan all output values for PII + piiFound := s.scanValue(req.Output) + if len(piiFound) == 0 { + // No PII detected - pass through + c.JSON(http.StatusOK, server.PostHookResult{Code: server.OK}) + return + } + + // Log what was found + log.Printf("[POST] PII detected in %s.%s output:", req.Tool.Toolkit, req.Tool.Name) + for _, match := range piiFound { + log.Printf(" - %s: %q", match.typeName, match.value) + } + + if s.action == "block" { + // Block the entire response + errMsg := fmt.Sprintf("Response blocked: %d PII item(s) detected (%s)", + len(piiFound), summarizeTypes(piiFound)) + log.Printf("[POST] Blocking response: %s", errMsg) + c.JSON(http.StatusOK, server.PostHookResult{ + Code: server.CHECKFAILED, + ErrorMessage: &errMsg, + }) + return + } + + // Redact PII in the output + redacted := s.redactValue(req.Output) + log.Printf("[POST] Redacted %d PII item(s) in output", len(piiFound)) + c.JSON(http.StatusOK, server.PostHookResult{ + Code: server.OK, + Override: &server.PostHookOverride{Output: redacted}, + }) +} + +// ============================================================================= +// PII Scanning and Redaction +// ============================================================================= + +type piiMatch struct { + typeName string + value string +} + +// scanMap recursively scans all string values in a map for PII. +func (s *RedactorServer) scanMap(m map[string]interface{}) []piiMatch { + var matches []piiMatch + for _, v := range m { + matches = append(matches, s.scanValue(v)...) + } + return matches +} + +func (s *RedactorServer) scanValue(v interface{}) []piiMatch { + var matches []piiMatch + switch val := v.(type) { + case string: + for _, p := range s.patterns { + found := p.Regex.FindAllString(val, -1) + for _, f := range found { + matches = append(matches, piiMatch{typeName: p.Name, value: f}) + } + } + case map[string]interface{}: + matches = append(matches, s.scanMap(val)...) + case []interface{}: + for _, item := range val { + matches = append(matches, s.scanValue(item)...) + } + } + return matches +} + +// redactMap recursively redacts PII in all string values of a map. +func (s *RedactorServer) redactMap(m map[string]interface{}) map[string]interface{} { + result := make(map[string]interface{}, len(m)) + for k, v := range m { + result[k] = s.redactValue(v) + } + return result +} + +func (s *RedactorServer) redactValue(v interface{}) interface{} { + switch val := v.(type) { + case string: + result := val + for _, p := range s.patterns { + result = p.Regex.ReplaceAllString(result, p.Replacement) + } + return result + case map[string]interface{}: + return s.redactMap(val) + case []interface{}: + result := make([]interface{}, len(val)) + for i, item := range val { + result[i] = s.redactValue(item) + } + return result + default: + return v + } +} + +// ============================================================================= +// Helpers +// ============================================================================= + +func (s *RedactorServer) validateAuth(c *gin.Context) bool { + if s.token == "" { + return true + } + auth := c.GetHeader("Authorization") + if auth != "Bearer "+s.token { + c.JSON(http.StatusUnauthorized, server.ErrorResponse{ + Error: strPtr("invalid or missing bearer token"), + Code: responseCodePtr(server.CHECKFAILED), + }) + return false + } + return true +} + +func summarizeTypes(matches []piiMatch) string { + seen := make(map[string]bool) + var types []string + for _, m := range matches { + if !seen[m.typeName] { + seen[m.typeName] = true + types = append(types, m.typeName) + } + } + return strings.Join(types, ", ") +} + +func strPtr(s string) *string { return &s } + +func responseCodePtr(c server.ResponseCode) *server.ResponseCode { return &c } + +// ============================================================================= +// Main +// ============================================================================= + +func main() { + var ( + port int + token string + action string + types string + ) + + flag.IntVar(&port, "port", 8888, "Port to listen on") + flag.StringVar(&token, "token", "", "Bearer token for authentication") + flag.StringVar(&action, "action", "redact", "Action when PII found: 'redact' or 'block'") + flag.StringVar(&types, "types", "email,ipv4,ssn,phone,credit_card,date_of_birth", "Comma-separated PII types to detect") + flag.Parse() + + enabledTypes := strings.Split(types, ",") + for i := range enabledTypes { + enabledTypes[i] = strings.TrimSpace(enabledTypes[i]) + } + + srv := NewRedactorServer(token, action, enabledTypes) + + fmt.Printf("\nPII Redactor Hook Server\n") + fmt.Printf(" Action: %s\n", action) + fmt.Printf(" PII types: %s\n", strings.Join(enabledTypes, ", ")) + fmt.Println() + + gin.SetMode(gin.ReleaseMode) + router := gin.New() + router.Use(gin.Recovery()) + server.RegisterHandlers(router, srv) + + addr := fmt.Sprintf(":%d", port) + fmt.Printf("Listening on %s\n", addr) + fmt.Printf(" POST /post - Scan and redact PII from tool outputs\n\n") + + if err := router.Run(addr); err != nil { + log.Fatal("Failed to start server:", err) + } +} diff --git a/examples/user_blocking/Dockerfile b/examples/user_blocking/Dockerfile new file mode 100644 index 0000000..5151be1 --- /dev/null +++ b/examples/user_blocking/Dockerfile @@ -0,0 +1,25 @@ +FROM --platform=$BUILDPLATFORM golang:1.25-alpine AS builder + +ARG TARGETOS +ARG TARGETARCH + +WORKDIR /src + +# Cache dependency downloads +COPY go.mod go.sum ./ +RUN go mod download + +# Copy shared package and example source +COPY pkg/ pkg/ +COPY examples/user_blocking/ examples/user_blocking/ + +RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \ + go build -ldflags="-s -w" -trimpath -o /bin/server ./examples/user_blocking + +FROM gcr.io/distroless/static-debian12 + +COPY --from=builder /bin/server /bin/server + +EXPOSE 8080 + +ENTRYPOINT ["/bin/server"] diff --git a/examples/user_blocking/README.md b/examples/user_blocking/README.md new file mode 100644 index 0000000..62ea64d --- /dev/null +++ b/examples/user_blocking/README.md @@ -0,0 +1,63 @@ +# User Blocking Example + +A minimal hook server that demonstrates how to **block specific users** from accessing and executing tools. + +## What It Shows + +- **Access hook**: Blocked users won't see any tools in the tool list +- **Pre-execution hook**: Blocked users can't execute tools (defense in depth) +- **Post-execution hook**: Pass-through (no modification needed) + +## Quick Start + +```bash +# Block users via command line +go run ./examples/user_blocking -block "user1,user2,user3" + +# Block users via config file +go run ./examples/user_blocking -config blocked-users.yaml +``` + +## Config File Format + +```yaml +blocked_users: + - user_id: "bad-user" + reason: "Account suspended" + - user_id: "former-employee" + reason: "No longer with organization" +``` + +## How It Works + +1. The **access hook** receives a list of all available tools and the requesting user +2. If the user is in the blocked list, ALL tools are added to the deny list +3. The **pre-execution hook** provides a second check - if a blocked user somehow gets past access control, execution is still denied +4. The **post-execution hook** is a pass-through since no output filtering is needed + +## Testing + +```bash +# Start the server +go run ./examples/user_blocking -block "blocked-user" & + +# Test access hook - user is blocked +curl -X POST http://localhost:8888/access \ + -H "Content-Type: application/json" \ + -d '{ + "user_id": "blocked-user", + "toolkits": { + "Email": {"tools": {"SendEmail": [{"version": "1.0.0"}]}} + } + }' + +# Test access hook - user is allowed +curl -X POST http://localhost:8888/access \ + -H "Content-Type: application/json" \ + -d '{ + "user_id": "good-user", + "toolkits": { + "Email": {"tools": {"SendEmail": [{"version": "1.0.0"}]}} + } + }' +``` diff --git a/examples/user_blocking/example-config.yaml b/examples/user_blocking/example-config.yaml new file mode 100644 index 0000000..da09ff2 --- /dev/null +++ b/examples/user_blocking/example-config.yaml @@ -0,0 +1,13 @@ +# User Blocking Configuration +# +# List of users who should be blocked from accessing and executing tools. + +blocked_users: + - user_id: "suspended-user" + reason: "Account suspended for policy violation" + + - user_id: "former-employee" + reason: "No longer with organization" + + - user_id: "test-bot" + reason: "Automated bot account - not authorized for tool access" diff --git a/examples/user_blocking/main.go b/examples/user_blocking/main.go new file mode 100644 index 0000000..13bad9a --- /dev/null +++ b/examples/user_blocking/main.go @@ -0,0 +1,241 @@ +// user_blocking demonstrates how to block specific users from accessing tools. +// +// This minimal hook server shows: +// - Blocking users in the access hook (they won't see the tools) +// - Blocking users in the pre-execution hook (they can't run the tools) +// - Using a simple YAML config with a list of blocked users +// +// Usage: +// +// go run ./examples/user_blocking -port 8888 -config blocked-users.yaml +// go run ./examples/user_blocking -port 8888 -block "user1,user2,user3" +package main + +import ( + "flag" + "fmt" + "log" + "net/http" + "os" + "strings" + + "github.com/gin-gonic/gin" + "gopkg.in/yaml.v3" + + "github.com/ArcadeAI/logical-extensions-examples/pkg/server" +) + +// Config defines the list of blocked users. +type Config struct { + BlockedUsers []BlockedUser `yaml:"blocked_users" json:"blocked_users"` +} + +// BlockedUser defines a user that should be blocked. +type BlockedUser struct { + UserID string `yaml:"user_id" json:"user_id"` + Reason string `yaml:"reason" json:"reason"` +} + +// BlockingServer implements the webhook ServerInterface. +type BlockingServer struct { + config *Config + token string +} + +func NewBlockingServer(cfg *Config, token string) *BlockingServer { + return &BlockingServer{config: cfg, token: token} +} + +// HealthCheck implements ServerInterface. +func (s *BlockingServer) HealthCheck(c *gin.Context) { + status := server.Healthy + c.JSON(http.StatusOK, server.HealthResponse{Status: &status}) +} + +// AccessHook implements ServerInterface. +// Blocked users will not see any tools. +func (s *BlockingServer) AccessHook(c *gin.Context) { + if !s.validateAuth(c) { + return + } + + var req server.AccessHookRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, server.ErrorResponse{ + Error: strPtr("invalid request body: " + err.Error()), + Code: responseCodePtr(server.CHECKFAILED), + }) + return + } + + // Check if user is blocked + if reason := s.isBlocked(req.UserId); reason != "" { + log.Printf("[ACCESS] Blocked user %q: %s", req.UserId, reason) + + // Deny ALL tools for blocked users + deny := make(server.Toolkits) + for toolkitName, toolkitInfo := range req.Toolkits { + deny[toolkitName] = toolkitInfo + } + c.JSON(http.StatusOK, server.AccessHookResult{Deny: &deny}) + return + } + + // Allow all tools for non-blocked users + log.Printf("[ACCESS] Allowed user %q", req.UserId) + c.JSON(http.StatusOK, server.AccessHookResult{}) +} + +// PreHook implements ServerInterface. +// Provides a second layer of defense - blocks execution even if access check is bypassed. +func (s *BlockingServer) PreHook(c *gin.Context) { + if !s.validateAuth(c) { + return + } + + var req server.PreHookRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, server.ErrorResponse{ + Error: strPtr("invalid request body: " + err.Error()), + Code: responseCodePtr(server.CHECKFAILED), + }) + return + } + + userID := "" + if req.Context.UserId != nil { + userID = *req.Context.UserId + } + + // Check if user is blocked + if reason := s.isBlocked(userID); reason != "" { + log.Printf("[PRE] Blocked execution for user %q on %s.%s: %s", userID, req.Tool.Toolkit, req.Tool.Name, reason) + errMsg := fmt.Sprintf("User is not authorized: %s", reason) + c.JSON(http.StatusOK, server.PreHookResult{ + Code: server.CHECKFAILED, + ErrorMessage: &errMsg, + }) + return + } + + log.Printf("[PRE] Allowed execution for user %q on %s.%s", userID, req.Tool.Toolkit, req.Tool.Name) + c.JSON(http.StatusOK, server.PreHookResult{Code: server.OK}) +} + +// PostHook implements ServerInterface. +// Pass-through - no post-processing needed for user blocking. +func (s *BlockingServer) PostHook(c *gin.Context) { + if !s.validateAuth(c) { + return + } + + // Read the request body but don't need to process it + var req server.PostHookRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, server.ErrorResponse{ + Error: strPtr("invalid request body: " + err.Error()), + Code: responseCodePtr(server.CHECKFAILED), + }) + return + } + + // Pass through - allow all post-execution responses + c.JSON(http.StatusOK, server.PostHookResult{Code: server.OK}) +} + +// isBlocked checks if a user is in the blocked list. +// Returns the reason if blocked, or empty string if allowed. +func (s *BlockingServer) isBlocked(userID string) string { + for _, blocked := range s.config.BlockedUsers { + if blocked.UserID == userID { + return blocked.Reason + } + } + return "" +} + +func (s *BlockingServer) validateAuth(c *gin.Context) bool { + if s.token == "" { + return true + } + auth := c.GetHeader("Authorization") + if auth != "Bearer "+s.token { + c.JSON(http.StatusUnauthorized, server.ErrorResponse{ + Error: strPtr("invalid or missing bearer token"), + Code: responseCodePtr(server.CHECKFAILED), + }) + return false + } + return true +} + +func strPtr(s string) *string { return &s } + +func responseCodePtr(c server.ResponseCode) *server.ResponseCode { return &c } + +func main() { + var ( + port int + token string + configFile string + blockList string + ) + + flag.IntVar(&port, "port", 8888, "Port to listen on") + flag.StringVar(&token, "token", "", "Bearer token for authentication") + flag.StringVar(&configFile, "config", "", "Path to YAML config file with blocked users") + flag.StringVar(&blockList, "block", "", "Comma-separated list of user IDs to block") + flag.Parse() + + cfg := &Config{} + + // Load from config file + if configFile != "" { + data, err := os.ReadFile(configFile) + if err != nil { + log.Fatalf("Failed to read config: %v", err) + } + if err := yaml.Unmarshal(data, cfg); err != nil { + log.Fatalf("Failed to parse config: %v", err) + } + log.Printf("Loaded %d blocked users from %s", len(cfg.BlockedUsers), configFile) + } + + // Add users from command line + if blockList != "" { + for _, uid := range strings.Split(blockList, ",") { + uid = strings.TrimSpace(uid) + if uid != "" { + cfg.BlockedUsers = append(cfg.BlockedUsers, BlockedUser{ + UserID: uid, + Reason: "blocked via command line", + }) + } + } + } + + if len(cfg.BlockedUsers) == 0 { + log.Println("Warning: no blocked users configured. Use -config or -block to specify users.") + } else { + log.Printf("Blocking %d users:", len(cfg.BlockedUsers)) + for _, u := range cfg.BlockedUsers { + log.Printf(" - %s (%s)", u.UserID, u.Reason) + } + } + + srv := NewBlockingServer(cfg, token) + + gin.SetMode(gin.ReleaseMode) + router := gin.New() + router.Use(gin.Recovery()) + server.RegisterHandlers(router, srv) + + addr := fmt.Sprintf(":%d", port) + fmt.Printf("\nUser Blocking Hook Server listening on %s\n", addr) + fmt.Printf(" POST /access - Block users from seeing tools\n") + fmt.Printf(" POST /pre - Block users from executing tools\n\n") + + if err := router.Run(addr); err != nil { + log.Fatal("Failed to start server:", err) + } +} diff --git a/hook-config.yaml b/hook-config.yaml new file mode 100644 index 0000000..9db425b --- /dev/null +++ b/hook-config.yaml @@ -0,0 +1,42 @@ +health: + status: healthy +access: + default_action: allow + rules: [] +pre: + default_action: proceed + rules: [] +post: + default_action: proceed + rules: [] +pii: + enabled: true + action: redact + types: + email: true + ipv4: true + ssn: true + phone: true + credit_card: true + date_of_birth: true + custom: [] +ab_testing: + enabled: true + tool_registry: + base_url: http://localhost:9099 + api_key: arc_o1EsEp4AR6cYUhfaeq3FRcRh6xW257au78C4uWWH2iWMXH2Mh2jR + experiments: + - name: Echo Test + enabled: true + toolkit: EchoServer + tool: '*' + mode: ab + variants: + - name: control + weight: 50 + version: 0.1.0 + server: null + - name: treatment + weight: 50 + version: 0.2.0 + server: null diff --git a/pkg/server/schema.gen.go b/pkg/server/schema.gen.go index e6035a6..0bd04c7 100644 --- a/pkg/server/schema.gen.go +++ b/pkg/server/schema.gen.go @@ -25,12 +25,6 @@ const ( RATELIMITEXCEEDED ResponseCode = "RATE_LIMIT_EXCEEDED" ) -// Defines values for ServerInfoType. -const ( - Arcade ServerInfoType = "arcade" - Mcp ServerInfoType = "mcp" -) - // AccessHookRequest Access-hook request from engine to hook server type AccessHookRequest struct { // Toolkits Map of a group of tools @@ -60,8 +54,8 @@ type Authorization struct { // ErrorResponse Error response from webhook server type ErrorResponse struct { - // Code Machine-readable error code for programmatic handling - Code *string `json:"code,omitempty"` + // Code Response code from hook server + Code *ResponseCode `json:"code,omitempty"` // Error Human-readable error message Error *string `json:"error,omitempty"` @@ -90,8 +84,8 @@ type OAuth2Details struct { // PostHookOverride Override response parameters type PostHookOverride struct { - // Output Override the response returned - Output *map[string]interface{} `json:"output,omitempty"` + // Output Override the output value (any JSON type — string, number, object, array, etc.) + Output interface{} `json:"output,omitempty"` } // PostHookRequest Post-hook request from engine to hook server @@ -111,11 +105,8 @@ type PostHookRequest struct { // Inputs Tool inputs (name -> value) Inputs *map[string]interface{} `json:"inputs,omitempty"` - // Output The execution output - Output map[string]interface{} `json:"output"` - - // Server Server routing information - Server ServerInfo `json:"server"` + // Output The tool's output value (any JSON type — string, number, object, array, etc.) + Output interface{} `json:"output,omitempty"` // Success Whether the tool succeeded Success *bool `json:"success,omitempty"` @@ -138,17 +129,11 @@ type PostHookResult struct { // PreHookOverride Override execution parameters type PreHookOverride struct { - // Headers Override request headers - Headers *map[string]string `json:"headers,omitempty"` - // Inputs Override tool inputs Inputs *map[string]interface{} `json:"inputs,omitempty"` // Secrets Override secrets Secrets *[]map[string]string `json:"secrets,omitempty"` - - // Server Server routing information - Server *ServerInfo `json:"server,omitempty"` } // PreHookRequest Pre-hook request from engine to hook server @@ -187,21 +172,6 @@ type SecretRequirement struct { Name string `json:"name"` } -// ServerInfo Server routing information -type ServerInfo struct { - // Name Server name - Name string `json:"name"` - - // Type Server type - Type ServerInfoType `json:"type"` - - // Uri Server URI - Uri string `json:"uri"` -} - -// ServerInfoType Server type -type ServerInfoType string - // ToolAuthRequirements Authorization requirements for a tool type ToolAuthRequirements struct { Oauth2 *struct { @@ -264,9 +234,6 @@ type ToolkitRequirements struct { // Secrets Required secrets Secrets *[]SecretRequirement `json:"secrets,omitempty"` - - // Server Server routing information - Server *ServerInfo `json:"server,omitempty"` } // Toolkits Map of a group of tools @@ -308,7 +275,6 @@ type MiddlewareFunc func(c *gin.Context) // AccessHook operation middleware func (siw *ServerInterfaceWrapper) AccessHook(c *gin.Context) { - c.Set(BearerAuthScopes, []string{}) for _, middleware := range siw.HandlerMiddlewares { @@ -323,7 +289,6 @@ func (siw *ServerInterfaceWrapper) AccessHook(c *gin.Context) { // HealthCheck operation middleware func (siw *ServerInterfaceWrapper) HealthCheck(c *gin.Context) { - for _, middleware := range siw.HandlerMiddlewares { middleware(c) if c.IsAborted() { @@ -336,7 +301,6 @@ func (siw *ServerInterfaceWrapper) HealthCheck(c *gin.Context) { // PostHook operation middleware func (siw *ServerInterfaceWrapper) PostHook(c *gin.Context) { - c.Set(BearerAuthScopes, []string{}) for _, middleware := range siw.HandlerMiddlewares { @@ -351,7 +315,6 @@ func (siw *ServerInterfaceWrapper) PostHook(c *gin.Context) { // PreHook operation middleware func (siw *ServerInterfaceWrapper) PreHook(c *gin.Context) { - c.Set(BearerAuthScopes, []string{}) for _, middleware := range siw.HandlerMiddlewares {