From 13a5f7b36fa6ec75ba9eb81b175f1b694de9c7aa Mon Sep 17 00:00:00 2001 From: Wils Dawson Date: Mon, 28 Sep 2026 15:16:22 -0700 Subject: [PATCH 01/12] fix: point example Dockerfiles at examples/contextual_access paths (PLT-3790) 10b2909 moved every example from examples// into examples/contextual_access// and updated the workflow's `file:` paths, but the Dockerfiles still COPY and build examples//. Every build-and-push job has failed on main since then with `"/examples/": not found`. Point each Dockerfile's COPY and go build at the new path. The same commit raised go.mod to `go 1.26.0`, which the golang:1.25-alpine builder rejects at `go mod download`, so move the builder to golang:1.26-alpine. Also update the .dockerignore entry for the committed advanced_server binary, which was moved too, so it stays out of the build context. Co-Authored-By: Claude Opus 5.5 (1M context) --- .dockerignore | 2 +- examples/contextual_access/ab_testing/Dockerfile | 6 +++--- examples/contextual_access/advanced_server/Dockerfile | 6 +++--- examples/contextual_access/basic_rules/Dockerfile | 6 +++--- examples/contextual_access/content_filter/Dockerfile | 6 +++--- examples/contextual_access/pii_redactor/Dockerfile | 6 +++--- examples/contextual_access/user_blocking/Dockerfile | 6 +++--- 7 files changed, 19 insertions(+), 19 deletions(-) diff --git a/.dockerignore b/.dockerignore index 36a7c09..2dba8ee 100644 --- a/.dockerignore +++ b/.dockerignore @@ -11,7 +11,7 @@ LICENSE # Compiled binaries advanced_server -examples/advanced_server/advanced_server +examples/contextual_access/advanced_server/advanced_server # IDE / editor .vscode diff --git a/examples/contextual_access/ab_testing/Dockerfile b/examples/contextual_access/ab_testing/Dockerfile index 7f9e1e6..05fbcb1 100644 --- a/examples/contextual_access/ab_testing/Dockerfile +++ b/examples/contextual_access/ab_testing/Dockerfile @@ -1,4 +1,4 @@ -FROM --platform=$BUILDPLATFORM golang:1.25-alpine AS builder +FROM --platform=$BUILDPLATFORM golang:1.26-alpine AS builder ARG TARGETOS ARG TARGETARCH @@ -11,10 +11,10 @@ RUN go mod download # Copy shared package and example source COPY pkg/ pkg/ -COPY examples/ab_testing/ examples/ab_testing/ +COPY examples/contextual_access/ab_testing/ examples/contextual_access/ab_testing/ RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \ - go build -ldflags="-s -w" -trimpath -o /bin/server ./examples/ab_testing + go build -ldflags="-s -w" -trimpath -o /bin/server ./examples/contextual_access/ab_testing FROM gcr.io/distroless/static-debian12 diff --git a/examples/contextual_access/advanced_server/Dockerfile b/examples/contextual_access/advanced_server/Dockerfile index 16a978a..530d3d5 100644 --- a/examples/contextual_access/advanced_server/Dockerfile +++ b/examples/contextual_access/advanced_server/Dockerfile @@ -1,4 +1,4 @@ -FROM --platform=$BUILDPLATFORM golang:1.25-alpine AS builder +FROM --platform=$BUILDPLATFORM golang:1.26-alpine AS builder ARG TARGETOS ARG TARGETARCH @@ -11,10 +11,10 @@ RUN go mod download # Copy shared package and example source COPY pkg/ pkg/ -COPY examples/advanced_server/ examples/advanced_server/ +COPY examples/contextual_access/advanced_server/ examples/contextual_access/advanced_server/ RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \ - go build -ldflags="-s -w" -trimpath -o /bin/server ./examples/advanced_server + go build -ldflags="-s -w" -trimpath -o /bin/server ./examples/contextual_access/advanced_server FROM gcr.io/distroless/static-debian12 diff --git a/examples/contextual_access/basic_rules/Dockerfile b/examples/contextual_access/basic_rules/Dockerfile index 536d04b..91ab63d 100644 --- a/examples/contextual_access/basic_rules/Dockerfile +++ b/examples/contextual_access/basic_rules/Dockerfile @@ -1,4 +1,4 @@ -FROM --platform=$BUILDPLATFORM golang:1.25-alpine AS builder +FROM --platform=$BUILDPLATFORM golang:1.26-alpine AS builder ARG TARGETOS ARG TARGETARCH @@ -11,10 +11,10 @@ RUN go mod download # Copy shared package and example source COPY pkg/ pkg/ -COPY examples/basic_rules/ examples/basic_rules/ +COPY examples/contextual_access/basic_rules/ examples/contextual_access/basic_rules/ RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \ - go build -ldflags="-s -w" -trimpath -o /bin/server ./examples/basic_rules + go build -ldflags="-s -w" -trimpath -o /bin/server ./examples/contextual_access/basic_rules FROM gcr.io/distroless/static-debian12 diff --git a/examples/contextual_access/content_filter/Dockerfile b/examples/contextual_access/content_filter/Dockerfile index 40a20e0..b71d6ab 100644 --- a/examples/contextual_access/content_filter/Dockerfile +++ b/examples/contextual_access/content_filter/Dockerfile @@ -1,4 +1,4 @@ -FROM --platform=$BUILDPLATFORM golang:1.25-alpine AS builder +FROM --platform=$BUILDPLATFORM golang:1.26-alpine AS builder ARG TARGETOS ARG TARGETARCH @@ -11,10 +11,10 @@ RUN go mod download # Copy shared package and example source COPY pkg/ pkg/ -COPY examples/content_filter/ examples/content_filter/ +COPY examples/contextual_access/content_filter/ examples/contextual_access/content_filter/ RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \ - go build -ldflags="-s -w" -trimpath -o /bin/server ./examples/content_filter + go build -ldflags="-s -w" -trimpath -o /bin/server ./examples/contextual_access/content_filter FROM gcr.io/distroless/static-debian12 diff --git a/examples/contextual_access/pii_redactor/Dockerfile b/examples/contextual_access/pii_redactor/Dockerfile index 608a18d..314d3c8 100644 --- a/examples/contextual_access/pii_redactor/Dockerfile +++ b/examples/contextual_access/pii_redactor/Dockerfile @@ -1,4 +1,4 @@ -FROM --platform=$BUILDPLATFORM golang:1.25-alpine AS builder +FROM --platform=$BUILDPLATFORM golang:1.26-alpine AS builder ARG TARGETOS ARG TARGETARCH @@ -11,10 +11,10 @@ RUN go mod download # Copy shared package and example source COPY pkg/ pkg/ -COPY examples/pii_redactor/ examples/pii_redactor/ +COPY examples/contextual_access/pii_redactor/ examples/contextual_access/pii_redactor/ RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \ - go build -ldflags="-s -w" -trimpath -o /bin/server ./examples/pii_redactor + go build -ldflags="-s -w" -trimpath -o /bin/server ./examples/contextual_access/pii_redactor FROM gcr.io/distroless/static-debian12 diff --git a/examples/contextual_access/user_blocking/Dockerfile b/examples/contextual_access/user_blocking/Dockerfile index 5151be1..cc478bc 100644 --- a/examples/contextual_access/user_blocking/Dockerfile +++ b/examples/contextual_access/user_blocking/Dockerfile @@ -1,4 +1,4 @@ -FROM --platform=$BUILDPLATFORM golang:1.25-alpine AS builder +FROM --platform=$BUILDPLATFORM golang:1.26-alpine AS builder ARG TARGETOS ARG TARGETARCH @@ -11,10 +11,10 @@ RUN go mod download # Copy shared package and example source COPY pkg/ pkg/ -COPY examples/user_blocking/ examples/user_blocking/ +COPY examples/contextual_access/user_blocking/ examples/contextual_access/user_blocking/ RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \ - go build -ldflags="-s -w" -trimpath -o /bin/server ./examples/user_blocking + go build -ldflags="-s -w" -trimpath -o /bin/server ./examples/contextual_access/user_blocking FROM gcr.io/distroless/static-debian12 From e91da63a829c60b89385ae9a2c95791c603337dc Mon Sep 17 00:00:00 2001 From: Wils Dawson Date: Mon, 28 Sep 2026 14:11:33 -0700 Subject: [PATCH 02/12] Regenerate webhook schema types (PLT-3790) Regenerate pkg/server/schema.gen.go from ArcadeAI/schemas main, which adds ContentBlock, the post-hook request's optional `content`, and `override.content`. It also picks up the tool metadata types (ToolBehavior, ToolClassification) that landed in the schema earlier. Related engine change: PLT-3746. Co-Authored-By: Claude Opus 5.5 (1M context) --- pkg/server/schema.gen.go | 128 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 128 insertions(+) diff --git a/pkg/server/schema.gen.go b/pkg/server/schema.gen.go index 0bd04c7..2c6c588 100644 --- a/pkg/server/schema.gen.go +++ b/pkg/server/schema.gen.go @@ -4,6 +4,9 @@ package server import ( + "encoding/json" + "fmt" + "github.com/gin-gonic/gin" ) @@ -52,6 +55,13 @@ type Authorization struct { ProviderId *string `json:"provider_id,omitempty"` } +// ContentBlock A block in Arcade's protocol-neutral content-block format: `text`, `image`, `audio`, `resource_link`, or `resource`, with `annotations` and `_meta`. The format follows the MCP content-block model, so MCP tooling and documentation apply, and `type` selects the block's other fields as MCP defines them: https://modelcontextprotocol.io/specification/2026-07-28/server/tools#tool-result. Content a server returns in another protocol would be converted into this format. +type ContentBlock struct { + // Type The block type, e.g. "text", "image", "audio", "resource_link", or "resource" + Type string `json:"type"` + AdditionalProperties map[string]interface{} `json:"-"` +} + // ErrorResponse Error response from webhook server type ErrorResponse struct { // Code Response code from hook server @@ -84,12 +94,18 @@ type OAuth2Details struct { // PostHookOverride Override response parameters type PostHookOverride struct { + // Content Replace the content blocks, in the same format as the request's `content`. Omit to forward the request's `content` unchanged. An empty list removes every block, and clients then receive `output` rendered as a single text block. A hook that rewrites `output` should rewrite `content` too, or the server's own text reaches the client as it was sent. + Content *[]ContentBlock `json:"content,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 type PostHookRequest struct { + // Content The content blocks the tool's server returned alongside its structured result, in Arcade's protocol-neutral content-block format (see `ContentBlock`). Today the only source is remote MCP servers. Fields the block definitions include are forwarded, annotations and `_meta` included, except that a `resource_link` block may arrive without its `title`, `size`, `icons`, or `_meta`. Fields outside the block definitions may be dropped. Clients receive the text blocks as the unstructured side of the result. Absent when the server sent no content of its own, in which case clients receive `output` rendered as text. Also absent on error results, whose error text arrives in `execution_error`. + Content *[]ContentBlock `json:"content,omitempty"` + // Context Tool execution context Context ToolContext `json:"context"` @@ -186,6 +202,30 @@ type ToolAuthRequirements struct { ProviderType *string `json:"provider_type,omitempty"` } +// ToolBehavior Behavior metadata for a tool +type ToolBehavior struct { + // Destructive Whether the tool can delete or irreversibly modify data + Destructive *bool `json:"destructive,omitempty"` + + // Idempotent Whether repeated calls with the same inputs produce the same result + Idempotent *bool `json:"idempotent,omitempty"` + + // OpenWorld Whether the tool can affect state outside its defined outputs + OpenWorld *bool `json:"open_world,omitempty"` + + // Operations Operations this tool performs (e.g., "read", "create", "update", "delete", "opaque"). Sourced from the tool's Behavior.Operations metadata. See https://docs.arcade.dev/en/guides/create-tools/tool-basics/add-tool-metadata for valid values. + Operations *[]string `json:"operations,omitempty"` + + // ReadOnly Whether the tool only reads data + ReadOnly *bool `json:"read_only,omitempty"` +} + +// ToolClassification Classification metadata for a tool +type ToolClassification struct { + // ServiceDomains Service domains this tool interfaces with (e.g., "crm", "email", "calendar"). Sourced from the tool's Classification.ServiceDomains metadata. See https://docs.arcade.dev/en/guides/create-tools/tool-basics/add-tool-metadata for valid values. + ServiceDomains *[]string `json:"service_domains,omitempty"` +} + // ToolContext Tool execution context type ToolContext struct { Authorization *[]Authorization `json:"authorization,omitempty"` @@ -202,6 +242,9 @@ type ToolContext struct { // ToolInfo Tool identification information type ToolInfo struct { + // Metadata Tool metadata + Metadata *ToolVersionInfoMetadata `json:"metadata,omitempty"` + // Name Tool name Name string `json:"name"` @@ -214,6 +257,9 @@ type ToolInfo struct { // ToolVersionInfo Version-specific information for a tool type ToolVersionInfo struct { + // Metadata Tool metadata + Metadata *ToolVersionInfoMetadata `json:"metadata,omitempty"` + // Requirements Requirements for a toolkit (group of tools) Requirements *ToolkitRequirements `json:"requirements,omitempty"` @@ -221,6 +267,18 @@ type ToolVersionInfo struct { Version *string `json:"version,omitempty"` } +// ToolVersionInfoMetadata Tool metadata +type ToolVersionInfoMetadata struct { + // Behavior Behavior metadata for a tool + Behavior *ToolBehavior `json:"behavior,omitempty"` + + // Classification Classification metadata for a tool + Classification *ToolClassification `json:"classification,omitempty"` + + // Extras Arbitrary additional metadata (e.g., {"IdP": "entra_id"}) + Extras *map[string]interface{} `json:"extras,omitempty"` +} + // ToolkitInfo Information about a group of tools type ToolkitInfo struct { // Tools Map of tool name to array of tool version info (there may be multiple versions of tools) @@ -248,6 +306,72 @@ type PostHookJSONRequestBody = PostHookRequest // PreHookJSONRequestBody defines body for PreHook for application/json ContentType. type PreHookJSONRequestBody = PreHookRequest +// Getter for additional properties for ContentBlock. Returns the specified +// element and whether it was found +func (a ContentBlock) Get(fieldName string) (value interface{}, found bool) { + if a.AdditionalProperties != nil { + value, found = a.AdditionalProperties[fieldName] + } + return +} + +// Setter for additional properties for ContentBlock +func (a *ContentBlock) Set(fieldName string, value interface{}) { + if a.AdditionalProperties == nil { + a.AdditionalProperties = make(map[string]interface{}) + } + a.AdditionalProperties[fieldName] = value +} + +// Override default JSON handling for ContentBlock to handle AdditionalProperties +func (a *ContentBlock) UnmarshalJSON(b []byte) error { + object := make(map[string]json.RawMessage) + err := json.Unmarshal(b, &object) + if err != nil { + return err + } + + if raw, found := object["type"]; found { + err = json.Unmarshal(raw, &a.Type) + if err != nil { + return fmt.Errorf("error reading 'type': %w", err) + } + delete(object, "type") + } + + if len(object) != 0 { + a.AdditionalProperties = make(map[string]interface{}) + for fieldName, fieldBuf := range object { + var fieldVal interface{} + err := json.Unmarshal(fieldBuf, &fieldVal) + if err != nil { + return fmt.Errorf("error unmarshaling field %s: %w", fieldName, err) + } + a.AdditionalProperties[fieldName] = fieldVal + } + } + return nil +} + +// Override default JSON handling for ContentBlock to handle AdditionalProperties +func (a ContentBlock) MarshalJSON() ([]byte, error) { + var err error + object := make(map[string]json.RawMessage) + + object["type"], err = json.Marshal(a.Type) + if err != nil { + return nil, fmt.Errorf("error marshaling 'type': %w", err) + } + + for fieldName, field := range a.AdditionalProperties { + object[fieldName], err = json.Marshal(field) + if err != nil { + return nil, fmt.Errorf("error marshaling '%s': %w", fieldName, err) + } + } + return json.Marshal(object) +} + // ServerInterface represents all server handlers. type ServerInterface interface { // Access control hook @@ -275,6 +399,7 @@ 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 { @@ -289,6 +414,7 @@ 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() { @@ -301,6 +427,7 @@ 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 { @@ -315,6 +442,7 @@ 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 { From f6091a43bddc2781edf1f855c6008c4735791fa0 Mon Sep 17 00:00:00 2001 From: Wils Dawson Date: Mon, 28 Sep 2026 15:38:56 -0700 Subject: [PATCH 03/12] Redact post-hook content alongside output (PLT-3790) Post-hook requests from remote MCP servers now carry `content`, the content blocks the server returned alongside its structured result, and clients see those blocks too. A hook that rewrites only `output` leaves the server's own text in `content` as it was sent. pii_redactor and content_filter now apply their existing output handling to `content` text blocks, the blocks MCP gateways render, and return the result as `override.content`. Other block types pass through unchanged. advanced_server, the comprehensive example, scans and redacts every string field of every block (text, uri, annotations, _meta, an embedded resource's text) and leaves base64 payloads (image/audio `data`, resource `blob`) as-is, since a regex match inside them would alter the encoded bytes. Block modes and keyword checks consider content too. Related engine change: PLT-3746. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 2 + .../advanced_server/README.md | 2 +- .../contextual_access/advanced_server/main.go | 36 ++++++++--- .../contextual_access/advanced_server/pii.go | 64 +++++++++++++++++++ .../content_filter/README.md | 6 +- .../contextual_access/content_filter/main.go | 45 +++++++++++-- .../contextual_access/pii_redactor/README.md | 4 +- .../contextual_access/pii_redactor/main.go | 49 +++++++++++--- 8 files changed, 182 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index fd58d31..a2f5f2d 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,8 @@ These servers implement webhook endpoints that integrate with an engine's hook s | `POST /pre` | Validate/modify tool inputs before execution | | `POST /post` | Validate/modify tool outputs after execution | +Post-hook requests from remote MCP servers also carry `content`, the content blocks the server returned alongside `output`. The examples that redact or filter `output` apply the same change to `content` and return it as `override.content`. MCP gateways render only text blocks, so pii_redactor and content_filter handle just those; advanced_server handles every block. + ## Architecture ``` diff --git a/examples/contextual_access/advanced_server/README.md b/examples/contextual_access/advanced_server/README.md index 34d16ef..6bdd131 100644 --- a/examples/contextual_access/advanced_server/README.md +++ b/examples/contextual_access/advanced_server/README.md @@ -104,7 +104,7 @@ See [example-config.yaml](example-config.yaml) for a full example with all optio ## PII Redaction Details -The PII redactor scans all string values in tool response outputs. When PII is detected: +The PII redactor scans all string values in tool response outputs, and the string fields of any `content` blocks (sent by remote MCP servers), returning the redacted blocks as `override.content`. Base64 payloads (`data`, `blob`) are left as-is. 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 diff --git a/examples/contextual_access/advanced_server/main.go b/examples/contextual_access/advanced_server/main.go index 60137ba..c5cdbdc 100644 --- a/examples/contextual_access/advanced_server/main.go +++ b/examples/contextual_access/advanced_server/main.go @@ -455,30 +455,36 @@ func (s *HookServer) evaluatePostRules(req server.PostHookRequest) (*server.Post // 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. + // Scan inputs, output, and content for PII — inputs may contain sensitive + // data that the tool could echo back, output may not always be populated, + // and content (the blocks a remote server sent alongside the output) + // reaches the client too. piiFound := false if cfg.PII != nil && cfg.PII.Enabled { - hasContent := req.Output != nil || (req.Inputs != nil && len(*req.Inputs) > 0) - if hasContent { + hasData := req.Output != nil || req.Content != nil || (req.Inputs != nil && len(*req.Inputs) > 0) + if hasData { detector := NewPIIDetector(cfg.PII) - // Scan both output and inputs for PII - var outputScan, inputScan PIIScanResult + // Scan output, content, and inputs for PII + var outputScan, contentScan, inputScan PIIScanResult if req.Output != nil { outputScan = detector.ScanAndSummarizeAny(req.Output) } + if req.Content != nil { + contentScan = detector.ScanAndSummarizeContent(*req.Content) + } if req.Inputs != nil { inputScan = detector.ScanAndSummarizeAny(*req.Inputs) } + outputPII := outputScan.ContainsPII || contentScan.ContainsPII - if outputScan.ContainsPII || inputScan.ContainsPII { + if outputPII || 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 { + if outputPII && inputScan.ContainsPII { errMsg = "Response blocked: PII detected in inputs and output" } else if inputScan.ContainsPII { errMsg = "Response blocked: PII detected in inputs" @@ -510,6 +516,20 @@ func (s *HookServer) evaluatePostRules(req server.PostHookRequest) (*server.Post } result.Override.Output = redacted } + + // Redact content too, or the server's original text reaches the + // client. As with output, a rule's override takes precedence. + contentToRedact := req.Content + if result.Override != nil && result.Override.Content != nil { + contentToRedact = result.Override.Content + } + if contentToRedact != nil { + redacted := detector.RedactContent(*contentToRedact) + if result.Override == nil { + result.Override = &server.PostHookOverride{} + } + result.Override.Content = &redacted + } return result, joinRuleMatch(ruleMatch, "pii:redact"), true } } diff --git a/examples/contextual_access/advanced_server/pii.go b/examples/contextual_access/advanced_server/pii.go index b15090a..b44bdd3 100644 --- a/examples/contextual_access/advanced_server/pii.go +++ b/examples/contextual_access/advanced_server/pii.go @@ -4,6 +4,8 @@ import ( "fmt" "regexp" "strings" + + "github.com/ArcadeAI/logical-extensions-examples/pkg/server" ) // ============================================================================= @@ -193,6 +195,21 @@ func (d *PIIDetector) ScanAndSummarize(data map[string]interface{}) PIIScanResul func (d *PIIDetector) ScanAndSummarizeAny(data interface{}) PIIScanResult { var matches []PIIMatch d.scanValue(data, "", &matches) + return summarize(matches) +} + +// ScanAndSummarizeContent scans the string fields of post-hook content blocks +// (text, uri, annotations, _meta, and an embedded resource's text) for PII and +// returns a summary. +func (d *PIIDetector) ScanAndSummarizeContent(blocks []server.ContentBlock) PIIScanResult { + var matches []PIIMatch + for i, b := range blocks { + d.scanBlockFields(b.AdditionalProperties, fmt.Sprintf("content[%d]", i), &matches) + } + return summarize(matches) +} + +func summarize(matches []PIIMatch) PIIScanResult { counts := make(map[string]int) for _, m := range matches { counts[m.Type]++ @@ -208,3 +225,50 @@ func (d *PIIDetector) ScanAndSummarizeAny(data interface{}) PIIScanResult { func (d *PIIDetector) RedactAny(data interface{}) interface{} { return d.redactValue(data) } + +// RedactContent redacts PII from the string fields of post-hook content blocks, +// keeping each block's type and base64 payloads unchanged. +func (d *PIIDetector) RedactContent(blocks []server.ContentBlock) []server.ContentBlock { + result := make([]server.ContentBlock, len(blocks)) + for i, b := range blocks { + result[i] = server.ContentBlock{Type: b.Type, AdditionalProperties: d.redactBlockFields(b.AdditionalProperties)} + } + return result +} + +// isBinaryField reports whether a content block field holds a base64 payload +// (image and audio "data", a resource's "blob"). Regexes could corrupt these, +// so they are left as-is. +func isBinaryField(key string) bool { + return key == "data" || key == "blob" +} + +func (d *PIIDetector) scanBlockFields(fields map[string]interface{}, path string, matches *[]PIIMatch) { + for key, val := range fields { + if isBinaryField(key) { + continue + } + newPath := path + "." + key + if resource, ok := val.(map[string]interface{}); ok && key == "resource" { + d.scanBlockFields(resource, newPath, matches) + continue + } + d.scanValue(val, newPath, matches) + } +} + +func (d *PIIDetector) redactBlockFields(fields map[string]interface{}) map[string]interface{} { + result := make(map[string]interface{}, len(fields)) + for key, val := range fields { + resource, isMap := val.(map[string]interface{}) + switch { + case isBinaryField(key): + result[key] = val + case isMap && key == "resource": + result[key] = d.redactBlockFields(resource) + default: + result[key] = d.redactValue(val) + } + } + return result +} diff --git a/examples/contextual_access/content_filter/README.md b/examples/contextual_access/content_filter/README.md index 3b9829c..47230d4 100644 --- a/examples/contextual_access/content_filter/README.md +++ b/examples/contextual_access/content_filter/README.md @@ -6,7 +6,7 @@ A minimal hook server that demonstrates how to **filter tool calls and responses - **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 +- **Post-execution hook**: Block or replace prohibited content in tool outputs and content text blocks ## Quick Start @@ -63,11 +63,11 @@ blocked_output_patterns: 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 +1. All tool output values, and any `content` text blocks (sent by remote MCP servers), 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 + - `action: "replace"` - Replace matching content with the replacement string, in both the output and `content` text blocks (returned as `override.content`). Other block types pass through unchanged. ## Testing diff --git a/examples/contextual_access/content_filter/main.go b/examples/contextual_access/content_filter/main.go index 82ab45a..4c73bcd 100644 --- a/examples/contextual_access/content_filter/main.go +++ b/examples/contextual_access/content_filter/main.go @@ -2,7 +2,7 @@ // // This minimal hook server shows: // - Blocking tool execution based on input content (pre-hook) -// - Blocking or replacing tool output based on content (post-hook) +// - Blocking or replacing tool output and content text blocks based on content (post-hook) // - Using keyword lists and pattern matching for content filtering // // Usage: @@ -196,7 +196,14 @@ func (s *FilterServer) PostHook(c *gin.Context) { return } + // Content text blocks a remote server sent alongside the output reach the + // client too, so they are checked and rewritten the same way. outputStr := flattenValue(req.Output) + var content []server.ContentBlock + if req.Content != nil { + content = *req.Content + outputStr += " " + flattenContent(content) + } // Check blocked keywords in output for _, keyword := range s.config.BlockedKeywords { @@ -229,8 +236,9 @@ func (s *FilterServer) PostHook(c *gin.Context) { return } if cp.rule.Action == "replace" { - // Replace matching content in all string values + // Replace matching content in all output string values and content text blocks result = replaceInValue(result, cp.pattern, cp.rule.Replacement) + content = replaceInText(content, cp.pattern, cp.rule.Replacement) modified = true log.Printf("[POST] Replaced content matching pattern %q", cp.rule.Name) } @@ -238,9 +246,13 @@ func (s *FilterServer) PostHook(c *gin.Context) { } if modified { + override := &server.PostHookOverride{Output: result} + if req.Content != nil { + override.Content = &content + } c.JSON(http.StatusOK, server.PostHookResult{ Code: server.OK, - Override: &server.PostHookOverride{Output: result}, + Override: override, }) return } @@ -331,6 +343,31 @@ func replaceInValue(v interface{}, pattern *regexp.Regexp, replacement string) i } } +// flattenContent converts content text blocks to a single string. Other block +// types pass through unchanged; extend this if your servers put text there. +func flattenContent(blocks []server.ContentBlock) string { + var parts []string + for _, b := range blocks { + if b.Type == "text" { + parts = append(parts, flattenValue(b.AdditionalProperties)) + } + } + return strings.Join(parts, " ") +} + +// replaceInText replaces regex matches in content text blocks and passes other +// blocks through unchanged. +func replaceInText(blocks []server.ContentBlock, pattern *regexp.Regexp, replacement string) []server.ContentBlock { + result := make([]server.ContentBlock, len(blocks)) + for i, b := range blocks { + result[i] = b + if b.Type == "text" { + result[i].AdditionalProperties = replaceInValue(b.AdditionalProperties, pattern, replacement).(map[string]interface{}) + } + } + return result +} + // matchGlob matches a glob pattern against a value. func matchGlob(pattern, value string) bool { if pattern == "" || pattern == "*" { @@ -404,7 +441,7 @@ func main() { 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") + fmt.Printf(" POST /post - Block or replace prohibited output and content text blocks\n\n") if err := router.Run(addr); err != nil { log.Fatal("Failed to start server:", err) diff --git a/examples/contextual_access/pii_redactor/README.md b/examples/contextual_access/pii_redactor/README.md index 7401976..00e660e 100644 --- a/examples/contextual_access/pii_redactor/README.md +++ b/examples/contextual_access/pii_redactor/README.md @@ -4,7 +4,7 @@ A minimal hook server that demonstrates how to **detect and redact personally id ## What It Shows -- **Post-execution hook**: Scans all string values in tool outputs for PII patterns +- **Post-execution hook**: Scans all string values in tool outputs and content text blocks 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 @@ -37,7 +37,7 @@ go run ./examples/pii_redactor -action block 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 +3. All string values in the output are recursively scanned for PII patterns, and so are `content` text blocks (sent by remote MCP servers). The redacted blocks are returned as `override.content`; other block types pass through unchanged. 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 diff --git a/examples/contextual_access/pii_redactor/main.go b/examples/contextual_access/pii_redactor/main.go index 3e03a2e..4959f0c 100644 --- a/examples/contextual_access/pii_redactor/main.go +++ b/examples/contextual_access/pii_redactor/main.go @@ -1,7 +1,7 @@ // 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.) +// - Scanning tool outputs and content text blocks for PII (emails, IPs, SSNs, phone numbers, etc.) // - Replacing detected PII with labeled placeholders // - Optionally blocking responses that contain PII instead of redacting // @@ -158,8 +158,12 @@ func (s *RedactorServer) PostHook(c *gin.Context) { return } - // Scan all output values for PII + // Scan all output values for PII, and the content text blocks a remote + // server sent alongside them: clients see those too, so both must be redacted. piiFound := s.scanValue(req.Output) + if req.Content != nil { + piiFound = append(piiFound, s.scanContent(*req.Content)...) + } if len(piiFound) == 0 { // No PII detected - pass through c.JSON(http.StatusOK, server.PostHookResult{Code: server.OK}) @@ -167,7 +171,7 @@ func (s *RedactorServer) PostHook(c *gin.Context) { } // Log what was found - log.Printf("[POST] PII detected in %s.%s output:", req.Tool.Toolkit, req.Tool.Name) + log.Printf("[POST] PII detected in %s.%s result:", req.Tool.Toolkit, req.Tool.Name) for _, match := range piiFound { log.Printf(" - %s: %q", match.typeName, match.value) } @@ -184,12 +188,16 @@ func (s *RedactorServer) PostHook(c *gin.Context) { return } - // Redact PII in the output - redacted := s.redactValue(req.Output) - log.Printf("[POST] Redacted %d PII item(s) in output", len(piiFound)) + // Redact PII in the output and content text blocks + override := &server.PostHookOverride{Output: s.redactValue(req.Output)} + if req.Content != nil { + content := s.redactContent(*req.Content) + override.Content = &content + } + log.Printf("[POST] Redacted %d PII item(s) in result", len(piiFound)) c.JSON(http.StatusOK, server.PostHookResult{ Code: server.OK, - Override: &server.PostHookOverride{Output: redacted}, + Override: override, }) } @@ -261,6 +269,31 @@ func (s *RedactorServer) redactValue(v interface{}) interface{} { } } +// scanContent scans text blocks for PII. Other block types pass through +// unscanned; extend this if your servers put text there. +func (s *RedactorServer) scanContent(blocks []server.ContentBlock) []piiMatch { + var matches []piiMatch + for _, b := range blocks { + if b.Type == "text" { + matches = append(matches, s.scanMap(b.AdditionalProperties)...) + } + } + return matches +} + +// redactContent redacts PII in text blocks and passes other blocks through +// unchanged. +func (s *RedactorServer) redactContent(blocks []server.ContentBlock) []server.ContentBlock { + result := make([]server.ContentBlock, len(blocks)) + for i, b := range blocks { + result[i] = b + if b.Type == "text" { + result[i].AdditionalProperties = s.redactMap(b.AdditionalProperties) + } + } + return result +} + // ============================================================================= // Helpers // ============================================================================= @@ -333,7 +366,7 @@ func main() { 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") + fmt.Printf(" POST /post - Scan and redact PII from tool outputs and content text blocks\n\n") if err := router.Run(addr); err != nil { log.Fatal("Failed to start server:", err) From 0cf3d4242ee1b65c3040768ebf0671d186200db8 Mon Sep 17 00:00:00 2001 From: Wils Dawson Date: Mon, 28 Sep 2026 14:12:06 -0700 Subject: [PATCH 04/12] Clear post-hook content when a rule overrides output (PLT-3790) basic_rules and advanced_server post rules can replace a tool's output with a fixed value. With a remote MCP server, the original `content` blocks would still reach the client beside the replacement. When the request carries content, such a rule now also returns an empty `override.content`, which removes the server's blocks so clients get the replacement output rendered as text. Related engine change: PLT-3746. Co-Authored-By: Claude Opus 5.5 (1M context) --- examples/contextual_access/advanced_server/README.md | 2 +- examples/contextual_access/advanced_server/main.go | 9 +++++++-- examples/contextual_access/basic_rules/README.md | 2 +- examples/contextual_access/basic_rules/main.go | 9 +++++++-- 4 files changed, 16 insertions(+), 6 deletions(-) diff --git a/examples/contextual_access/advanced_server/README.md b/examples/contextual_access/advanced_server/README.md index 6bdd131..e3b0c96 100644 --- a/examples/contextual_access/advanced_server/README.md +++ b/examples/contextual_access/advanced_server/README.md @@ -7,7 +7,7 @@ A comprehensive hook server with a web dashboard for managing access rules, PII ### 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 +- **Post-execution rules**: Block or modify tool responses after execution. A rule that overrides the output also clears the server's `content` blocks, so clients get the new output instead of the original text. - **Pattern matching**: Exact, glob (`*`), and regex (`~pattern`) patterns - **Input/output matching**: Filter based on request content diff --git a/examples/contextual_access/advanced_server/main.go b/examples/contextual_access/advanced_server/main.go index c5cdbdc..9e57042 100644 --- a/examples/contextual_access/advanced_server/main.go +++ b/examples/contextual_access/advanced_server/main.go @@ -440,7 +440,7 @@ func (s *HookServer) evaluatePostRules(req server.PostHookRequest) (*server.Post ruleMatch := "" for i, rule := range postCfg.Rules { if s.matchPostRule(rule, userID, req) { - result = s.applyPostRule(rule) + result = s.applyPostRule(rule, req) ruleMatch = fmt.Sprintf("post.rules[%d]", i) break } @@ -564,7 +564,7 @@ func (s *HookServer) matchPostRule(rule PostRule, userID string, req server.Post return true } -func (s *HookServer) applyPostRule(rule PostRule) *server.PostHookResult { +func (s *HookServer) applyPostRule(rule PostRule, req server.PostHookRequest) *server.PostHookResult { result := &server.PostHookResult{ Code: actionToCode(rule.Action), } @@ -579,6 +579,11 @@ func (s *HookServer) applyPostRule(rule PostRule) *server.PostHookResult { result.Override = &server.PostHookOverride{ Output: output, } + // Clear the server's content blocks so clients get the + // replacement output (as text) instead of the original result. + if req.Content != nil { + result.Override.Content = &[]server.ContentBlock{} + } } } diff --git a/examples/contextual_access/basic_rules/README.md b/examples/contextual_access/basic_rules/README.md index b81f0e5..369aa49 100644 --- a/examples/contextual_access/basic_rules/README.md +++ b/examples/contextual_access/basic_rules/README.md @@ -33,7 +33,7 @@ The server can be configured via a YAML file that supports: - **Access control**: Allow/deny tools based on user, toolkit, or tool name - **Pre-execution hooks**: Block execution or modify inputs, secrets, headers, server routing -- **Post-execution hooks**: Block responses or modify outputs +- **Post-execution hooks**: Block responses or modify outputs. An output override also clears the server's `content` blocks (sent by remote MCP servers), so clients get the new output instead of the original text. - **Pattern matching**: Exact match, glob patterns (`*`), or regex (`~pattern`) ### Example Configuration diff --git a/examples/contextual_access/basic_rules/main.go b/examples/contextual_access/basic_rules/main.go index 981c47c..52fa2b7 100644 --- a/examples/contextual_access/basic_rules/main.go +++ b/examples/contextual_access/basic_rules/main.go @@ -488,7 +488,7 @@ func (ts *TestServer) evaluatePostRules(req server.PostHookRequest) (*server.Pos for i, rule := range postCfg.Rules { if ts.matchPostRule(rule, userID, req) { - result := ts.applyPostRule(rule) + result := ts.applyPostRule(rule, req) return result, fmt.Sprintf("post.rules[%d]", i) } } @@ -524,7 +524,7 @@ func (ts *TestServer) matchPostRule(rule PostRule, userID string, req server.Pos return true } -func (ts *TestServer) applyPostRule(rule PostRule) *server.PostHookResult { +func (ts *TestServer) applyPostRule(rule PostRule, req server.PostHookRequest) *server.PostHookResult { result := &server.PostHookResult{ Code: ts.actionToCode(rule.Action), } @@ -539,6 +539,11 @@ func (ts *TestServer) applyPostRule(rule PostRule) *server.PostHookResult { result.Override = &server.PostHookOverride{ Output: output, } + // Clear the server's content blocks so clients get the + // replacement output (as text) instead of the original result. + if req.Content != nil { + result.Override.Content = &[]server.ContentBlock{} + } } } From 8aaec3071fada71e62e20d31805941de55aae0a7 Mon Sep 17 00:00:00 2001 From: Wils Dawson Date: Mon, 28 Sep 2026 15:44:21 -0700 Subject: [PATCH 05/12] Match content_filter post-hook rules per field (PLT-3790) The post-hook flattened output and content into one string before matching rules. An anchored pattern such as ^secret$ could then never match: with no output, the flattened string starts with "", so a text block containing exactly "secret" passed through unfiltered, and with several output fields the join defeated anchors too. Check keywords and patterns against each output value and each value in content text blocks on its own. Replacements were already applied per string, so they are unchanged. The content matching was introduced in f6091a4. Refs PLT-3790. Related engine change: PLT-3746. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../content_filter/README.md | 2 +- .../contextual_access/content_filter/main.go | 59 +++++++++++++++---- 2 files changed, 48 insertions(+), 13 deletions(-) diff --git a/examples/contextual_access/content_filter/README.md b/examples/contextual_access/content_filter/README.md index 47230d4..1cd7470 100644 --- a/examples/contextual_access/content_filter/README.md +++ b/examples/contextual_access/content_filter/README.md @@ -63,7 +63,7 @@ blocked_output_patterns: 4. If any match is found, the tool execution is blocked with an error message ### Output Filtering (Post-Hook) -1. All tool output values, and any `content` text blocks (sent by remote MCP servers), are flattened into a single string +1. Each tool output value, and each value in `content` text blocks (sent by remote MCP servers), is checked on its own 2. Blocked keywords are checked 3. Blocked output patterns are checked: - `action: "block"` - Reject the entire response diff --git a/examples/contextual_access/content_filter/main.go b/examples/contextual_access/content_filter/main.go index 4c73bcd..ff915a1 100644 --- a/examples/contextual_access/content_filter/main.go +++ b/examples/contextual_access/content_filter/main.go @@ -196,18 +196,21 @@ func (s *FilterServer) PostHook(c *gin.Context) { return } - // Content text blocks a remote server sent alongside the output reach the - // client too, so they are checked and rewritten the same way. - outputStr := flattenValue(req.Output) + // Rules are checked against each output value on its own, so anchored + // patterns work. Content text blocks a remote server sent alongside the + // output reach the client too, so they are checked and rewritten the same way. + fields := leafValues(req.Output) var content []server.ContentBlock if req.Content != nil { content = *req.Content - outputStr += " " + flattenContent(content) + fields = append(fields, contentValues(content)...) } // Check blocked keywords in output for _, keyword := range s.config.BlockedKeywords { - if strings.Contains(strings.ToLower(outputStr), strings.ToLower(keyword)) { + if anyField(fields, func(f string) bool { + return strings.Contains(strings.ToLower(f), strings.ToLower(keyword)) + }) { errMsg := fmt.Sprintf("Output contains blocked content: %q", keyword) log.Printf("[POST] Blocked: %s", errMsg) c.JSON(http.StatusOK, server.PostHookResult{ @@ -222,7 +225,7 @@ func (s *FilterServer) PostHook(c *gin.Context) { modified := false result := copyValue(req.Output) for _, cp := range s.compiledOutputs { - if cp.pattern.MatchString(outputStr) { + if anyField(fields, cp.pattern.MatchString) { if cp.rule.Action == "block" { msg := cp.rule.Message if msg == "" { @@ -343,16 +346,48 @@ func replaceInValue(v interface{}, pattern *regexp.Regexp, replacement string) i } } -// flattenContent converts content text blocks to a single string. Other block -// types pass through unchanged; extend this if your servers put text there. -func flattenContent(blocks []server.ContentBlock) string { - var parts []string +// leafValues collects every non-null value in v, recursively, as a string. +func leafValues(v interface{}) []string { + switch val := v.(type) { + case nil: + return nil + case map[string]interface{}: + var leaves []string + for _, item := range val { + leaves = append(leaves, leafValues(item)...) + } + return leaves + case []interface{}: + var leaves []string + for _, item := range val { + leaves = append(leaves, leafValues(item)...) + } + return leaves + default: + return []string{flattenValue(val)} + } +} + +// contentValues collects the values in content text blocks. Other block types +// pass through unchanged; extend this if your servers put text there. +func contentValues(blocks []server.ContentBlock) []string { + var leaves []string for _, b := range blocks { if b.Type == "text" { - parts = append(parts, flattenValue(b.AdditionalProperties)) + leaves = append(leaves, leafValues(b.AdditionalProperties)...) + } + } + return leaves +} + +// anyField reports whether match is true for any of fields. +func anyField(fields []string, match func(string) bool) bool { + for _, f := range fields { + if match(f) { + return true } } - return strings.Join(parts, " ") + return false } // replaceInText replaces regex matches in content text blocks and passes other From 9849e782f9dcd43843d5f4ae94358f7bac777746 Mon Sep 17 00:00:00 2001 From: Wils Dawson Date: Mon, 28 Sep 2026 15:55:37 -0700 Subject: [PATCH 06/12] chore: point go run instructions at examples/contextual_access paths (PLT-3790) The READMEs, usage comments, example configs, and the cert script still used ./examples/ from before the move (and ./tools/webhook-test-server for basic_rules). They now use ./examples/contextual_access/, run from the repository root. Co-Authored-By: Claude Opus 5.5 (1M context) --- examples/contextual_access/ab_testing/README.md | 4 ++-- examples/contextual_access/ab_testing/main.go | 2 +- examples/contextual_access/advanced_server/README.md | 8 ++++---- .../advanced_server/example-config.yaml | 2 +- examples/contextual_access/advanced_server/main.go | 4 ++-- examples/contextual_access/basic_rules/README.md | 9 +++------ .../contextual_access/basic_rules/example-config.yaml | 2 +- .../contextual_access/basic_rules/generate-test-certs.sh | 2 +- examples/contextual_access/basic_rules/main.go | 6 +++--- examples/contextual_access/content_filter/README.md | 4 ++-- examples/contextual_access/content_filter/main.go | 4 ++-- examples/contextual_access/pii_redactor/README.md | 8 ++++---- examples/contextual_access/pii_redactor/main.go | 6 +++--- examples/contextual_access/user_blocking/README.md | 6 +++--- examples/contextual_access/user_blocking/main.go | 4 ++-- 15 files changed, 34 insertions(+), 37 deletions(-) diff --git a/examples/contextual_access/ab_testing/README.md b/examples/contextual_access/ab_testing/README.md index d6d0935..0d4df18 100644 --- a/examples/contextual_access/ab_testing/README.md +++ b/examples/contextual_access/ab_testing/README.md @@ -14,7 +14,7 @@ A minimal hook server that demonstrates how to **A/B test and canary-deploy tool ```bash # Run with experiment config -go run ./examples/ab_testing -config experiments.yaml +go run ./examples/contextual_access/ab_testing -config experiments.yaml ``` ## Config File Format @@ -79,7 +79,7 @@ experiments: ```bash # Start with example config -go run ./examples/ab_testing -config experiments.yaml & +go run ./examples/contextual_access/ab_testing -config experiments.yaml & # Send pre-hook requests for different users for i in $(seq 1 20); do diff --git a/examples/contextual_access/ab_testing/main.go b/examples/contextual_access/ab_testing/main.go index 3e57f06..dd3e777 100644 --- a/examples/contextual_access/ab_testing/main.go +++ b/examples/contextual_access/ab_testing/main.go @@ -8,7 +8,7 @@ // // Usage: // -// go run ./examples/ab_testing -port 8888 -config experiments.yaml +// go run ./examples/contextual_access/ab_testing -port 8888 -config experiments.yaml package main import ( diff --git a/examples/contextual_access/advanced_server/README.md b/examples/contextual_access/advanced_server/README.md index e3b0c96..3693b7b 100644 --- a/examples/contextual_access/advanced_server/README.md +++ b/examples/contextual_access/advanced_server/README.md @@ -35,16 +35,16 @@ A comprehensive hook server with a web dashboard for managing access rules, PII ```bash # Run with defaults (port 8888, no auth) -go run ./examples/advanced_server +go run ./examples/contextual_access/advanced_server # Run with a configuration file -go run ./examples/advanced_server -config ./examples/advanced_server/example-config.yaml +go run ./examples/contextual_access/advanced_server -config ./examples/contextual_access/advanced_server/example-config.yaml # Run with authentication -go run ./examples/advanced_server -token "my-secret-token" +go run ./examples/contextual_access/advanced_server -token "my-secret-token" # Run with TLS -go run ./examples/advanced_server -tls -cert server.crt -key server.key +go run ./examples/contextual_access/advanced_server -tls -cert server.crt -key server.key ``` Then open `http://localhost:8888/` in your browser to access the dashboard. diff --git a/examples/contextual_access/advanced_server/example-config.yaml b/examples/contextual_access/advanced_server/example-config.yaml index 97ac877..305b3a3 100644 --- a/examples/contextual_access/advanced_server/example-config.yaml +++ b/examples/contextual_access/advanced_server/example-config.yaml @@ -3,7 +3,7 @@ # 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 +# Usage: go run ./examples/contextual_access/advanced_server -config example-config.yaml # Health endpoint configuration health: diff --git a/examples/contextual_access/advanced_server/main.go b/examples/contextual_access/advanced_server/main.go index 9e57042..d1c4262 100644 --- a/examples/contextual_access/advanced_server/main.go +++ b/examples/contextual_access/advanced_server/main.go @@ -8,8 +8,8 @@ // // Usage: // -// go run ./examples/advanced_server -port 8888 -config config.yaml -// go run ./examples/advanced_server -port 8888 -token secret123 +// go run ./examples/contextual_access/advanced_server -port 8888 -config config.yaml +// go run ./examples/contextual_access/advanced_server -port 8888 -token secret123 package main import ( diff --git a/examples/contextual_access/basic_rules/README.md b/examples/contextual_access/basic_rules/README.md index 369aa49..ebcf92a 100644 --- a/examples/contextual_access/basic_rules/README.md +++ b/examples/contextual_access/basic_rules/README.md @@ -5,17 +5,14 @@ A configurable test server for validating the CATE webhook hook system. It imple ## Quick Start ```bash -# From the engine directory -cd apps/engine - # Run with defaults (port 8888, no auth, allow all) -go run ./tools/webhook-test-server +go run ./examples/contextual_access/basic_rules # Run with authentication -go run ./tools/webhook-test-server -token "my-secret-token" +go run ./examples/contextual_access/basic_rules -token "my-secret-token" # Run with configuration file (enables blocking/modification rules) -go run ./tools/webhook-test-server -config ./tools/webhook-test-server/example-config.yaml +go run ./examples/contextual_access/basic_rules -config ./examples/contextual_access/basic_rules/example-config.yaml ``` ## Command Line Flags diff --git a/examples/contextual_access/basic_rules/example-config.yaml b/examples/contextual_access/basic_rules/example-config.yaml index ff6a336..04d424b 100644 --- a/examples/contextual_access/basic_rules/example-config.yaml +++ b/examples/contextual_access/basic_rules/example-config.yaml @@ -3,7 +3,7 @@ # This file demonstrates all available configuration options. # The server hot-reloads this file when it changes. # -# Usage: go run ./tools/webhook-test-server -config example-config.yaml +# Usage: go run ./examples/contextual_access/basic_rules -config example-config.yaml # Health endpoint configuration health: diff --git a/examples/contextual_access/basic_rules/generate-test-certs.sh b/examples/contextual_access/basic_rules/generate-test-certs.sh index 9f0149d..0668b54 100755 --- a/examples/contextual_access/basic_rules/generate-test-certs.sh +++ b/examples/contextual_access/basic_rules/generate-test-certs.sh @@ -81,7 +81,7 @@ echo " client.crt - Client certificate (use as client_cert in plugin config)" echo " client.key - Client private key (use as client_key in plugin config)" echo "" echo "To start the mTLS test server:" -echo " go run ./tools/webhook-test-server -port 8888 -tls -cert server.crt -key server.key -ca ca.crt" +echo " go run ./examples/contextual_access/basic_rules -port 8888 -tls -cert server.crt -key server.key -ca ca.crt" echo "" echo "To test with curl:" echo " curl --cacert ca.crt --cert client.crt --key client.key https://localhost:8888/health" diff --git a/examples/contextual_access/basic_rules/main.go b/examples/contextual_access/basic_rules/main.go index 52fa2b7..b8da640 100644 --- a/examples/contextual_access/basic_rules/main.go +++ b/examples/contextual_access/basic_rules/main.go @@ -4,13 +4,13 @@ // Usage: // // # Basic HTTP server with bearer token auth -// go run ./tools/webhook-test-server -port 8888 -token secret123 -config config.yaml +// go run ./examples/contextual_access/basic_rules -port 8888 -token secret123 -config config.yaml // // # HTTPS server (TLS) -// go run ./tools/webhook-test-server -port 8888 -tls -cert server.crt -key server.key +// go run ./examples/contextual_access/basic_rules -port 8888 -tls -cert server.crt -key server.key // // # mTLS server (requires client certificate) -// go run ./tools/webhook-test-server -port 8888 -tls -cert server.crt -key server.key -ca ca.crt +// go run ./examples/contextual_access/basic_rules -port 8888 -tls -cert server.crt -key server.key -ca ca.crt // // The server logs all incoming requests and provides configurable responses. package main diff --git a/examples/contextual_access/content_filter/README.md b/examples/contextual_access/content_filter/README.md index 1cd7470..7e0c62c 100644 --- a/examples/contextual_access/content_filter/README.md +++ b/examples/contextual_access/content_filter/README.md @@ -12,7 +12,7 @@ A minimal hook server that demonstrates how to **filter tool calls and responses ```bash # Run with a config file -go run ./examples/content_filter -config filter-rules.yaml +go run ./examples/contextual_access/content_filter -config filter-rules.yaml ``` ## Config File Format @@ -73,7 +73,7 @@ blocked_output_patterns: ```bash # Start the server with example rules -go run ./examples/content_filter -config filter-rules.yaml & +go run ./examples/contextual_access/content_filter -config filter-rules.yaml & # Test pre-hook - should be blocked (contains blocked keyword) curl -X POST http://localhost:8888/pre \ diff --git a/examples/contextual_access/content_filter/main.go b/examples/contextual_access/content_filter/main.go index ff915a1..1ec79b8 100644 --- a/examples/contextual_access/content_filter/main.go +++ b/examples/contextual_access/content_filter/main.go @@ -7,8 +7,8 @@ // // Usage: // -// go run ./examples/content_filter -port 8888 -// go run ./examples/content_filter -port 8888 -config filter-rules.yaml +// go run ./examples/contextual_access/content_filter -port 8888 +// go run ./examples/contextual_access/content_filter -port 8888 -config filter-rules.yaml package main import ( diff --git a/examples/contextual_access/pii_redactor/README.md b/examples/contextual_access/pii_redactor/README.md index 00e660e..9787a47 100644 --- a/examples/contextual_access/pii_redactor/README.md +++ b/examples/contextual_access/pii_redactor/README.md @@ -13,13 +13,13 @@ A minimal hook server that demonstrates how to **detect and redact personally id ```bash # Redact all PII types (default) -go run ./examples/pii_redactor +go run ./examples/contextual_access/pii_redactor # Only detect specific PII types -go run ./examples/pii_redactor -types "email,ssn,credit_card" +go run ./examples/contextual_access/pii_redactor -types "email,ssn,credit_card" # Block responses instead of redacting -go run ./examples/pii_redactor -action block +go run ./examples/contextual_access/pii_redactor -action block ``` ## Supported PII Types @@ -46,7 +46,7 @@ go run ./examples/pii_redactor -action block ```bash # Start the server -go run ./examples/pii_redactor & +go run ./examples/contextual_access/pii_redactor & # Test with PII in output - will be redacted curl -X POST http://localhost:8888/post \ diff --git a/examples/contextual_access/pii_redactor/main.go b/examples/contextual_access/pii_redactor/main.go index 4959f0c..7f2ebf1 100644 --- a/examples/contextual_access/pii_redactor/main.go +++ b/examples/contextual_access/pii_redactor/main.go @@ -7,9 +7,9 @@ // // 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" +// go run ./examples/contextual_access/pii_redactor -port 8888 +// go run ./examples/contextual_access/pii_redactor -port 8888 -action block +// go run ./examples/contextual_access/pii_redactor -port 8888 -types "email,ssn,credit_card" package main import ( diff --git a/examples/contextual_access/user_blocking/README.md b/examples/contextual_access/user_blocking/README.md index 62ea64d..a480720 100644 --- a/examples/contextual_access/user_blocking/README.md +++ b/examples/contextual_access/user_blocking/README.md @@ -12,10 +12,10 @@ A minimal hook server that demonstrates how to **block specific users** from acc ```bash # Block users via command line -go run ./examples/user_blocking -block "user1,user2,user3" +go run ./examples/contextual_access/user_blocking -block "user1,user2,user3" # Block users via config file -go run ./examples/user_blocking -config blocked-users.yaml +go run ./examples/contextual_access/user_blocking -config blocked-users.yaml ``` ## Config File Format @@ -39,7 +39,7 @@ blocked_users: ```bash # Start the server -go run ./examples/user_blocking -block "blocked-user" & +go run ./examples/contextual_access/user_blocking -block "blocked-user" & # Test access hook - user is blocked curl -X POST http://localhost:8888/access \ diff --git a/examples/contextual_access/user_blocking/main.go b/examples/contextual_access/user_blocking/main.go index 13bad9a..cf3cfd8 100644 --- a/examples/contextual_access/user_blocking/main.go +++ b/examples/contextual_access/user_blocking/main.go @@ -7,8 +7,8 @@ // // Usage: // -// go run ./examples/user_blocking -port 8888 -config blocked-users.yaml -// go run ./examples/user_blocking -port 8888 -block "user1,user2,user3" +// go run ./examples/contextual_access/user_blocking -port 8888 -config blocked-users.yaml +// go run ./examples/contextual_access/user_blocking -port 8888 -block "user1,user2,user3" package main import ( From ee0025f951178493d036275a6851b69985edc474 Mon Sep 17 00:00:00 2001 From: Wils Dawson Date: Mon, 28 Sep 2026 15:57:08 -0700 Subject: [PATCH 07/12] fix: match content_filter pre-hook rules per field (PLT-3790) The pre-hook joined every input value into one string, so an anchored input pattern missed when a tool had more than one input. It now checks each input value on its own with leafValues and anyField, as the post-hook does since 8aaec30. Co-Authored-By: Claude Opus 5.5 (1M context) --- examples/contextual_access/content_filter/README.md | 2 +- examples/contextual_access/content_filter/main.go | 11 +++++++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/examples/contextual_access/content_filter/README.md b/examples/contextual_access/content_filter/README.md index 7e0c62c..80e6b72 100644 --- a/examples/contextual_access/content_filter/README.md +++ b/examples/contextual_access/content_filter/README.md @@ -57,7 +57,7 @@ blocked_output_patterns: ## How It Works ### Input Filtering (Pre-Hook) -1. All tool input values are flattened into a single string +1. Each tool input value is checked on its own 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 diff --git a/examples/contextual_access/content_filter/main.go b/examples/contextual_access/content_filter/main.go index 1ec79b8..639f60f 100644 --- a/examples/contextual_access/content_filter/main.go +++ b/examples/contextual_access/content_filter/main.go @@ -144,12 +144,15 @@ func (s *FilterServer) PreHook(c *gin.Context) { return } - // Serialize all inputs to a single string for keyword/pattern checking - inputStr := flattenValue(req.Inputs) + // Rules are checked against each input value on its own, so anchored + // patterns work. + fields := leafValues(req.Inputs) // Check blocked keywords in inputs for _, keyword := range s.config.BlockedKeywords { - if strings.Contains(strings.ToLower(inputStr), strings.ToLower(keyword)) { + if anyField(fields, func(f string) bool { + return strings.Contains(strings.ToLower(f), strings.ToLower(keyword)) + }) { errMsg := fmt.Sprintf("Input contains blocked content: %q", keyword) log.Printf("[PRE] Blocked: %s", errMsg) c.JSON(http.StatusOK, server.PreHookResult{ @@ -162,7 +165,7 @@ func (s *FilterServer) PreHook(c *gin.Context) { // Check regex patterns against inputs for _, cp := range s.compiledInputs { - if cp.pattern.MatchString(inputStr) { + if anyField(fields, cp.pattern.MatchString) { msg := cp.rule.Message if msg == "" { msg = fmt.Sprintf("Input matched blocked pattern: %s", cp.rule.Name) From 01fed3929a36822d5ca0b59b4be9923fe9cc9de7 Mon Sep 17 00:00:00 2001 From: Wils Dawson Date: Mon, 28 Sep 2026 15:59:22 -0700 Subject: [PATCH 08/12] fix: redact the whole phone number, including a leading ( or + (PLT-3790) The phone pattern began with \b, which can't match before "(" or "+", so "(555) 123-4567" became "([PHONE REDACTED]" and "+1 555-123-4567" became "+[PHONE REDACTED]". The match can now start at "(" or "+". It matches the same strings as before and only extends the match to the left. Co-Authored-By: Claude Opus 5.5 (1M context) --- examples/contextual_access/advanced_server/pii.go | 2 +- examples/contextual_access/pii_redactor/main.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/contextual_access/advanced_server/pii.go b/examples/contextual_access/advanced_server/pii.go index b44bdd3..f602192 100644 --- a/examples/contextual_access/advanced_server/pii.go +++ b/examples/contextual_access/advanced_server/pii.go @@ -42,7 +42,7 @@ func NewPIIDetector(cfg *PIIConfig) *PIIDetector { 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.patterns["phone"] = regexp.MustCompile(`(?:\+?\b1[-.\s]?\(?|\(|\b)\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}\b`) d.labels["phone"] = "[PHONE REDACTED]" } if cfg.Types.CreditCard { diff --git a/examples/contextual_access/pii_redactor/main.go b/examples/contextual_access/pii_redactor/main.go index 7f2ebf1..ec35b0e 100644 --- a/examples/contextual_access/pii_redactor/main.go +++ b/examples/contextual_access/pii_redactor/main.go @@ -56,7 +56,7 @@ func AllPIIPatterns() map[string]PIIPattern { }, "phone": { Name: "phone", - Regex: regexp.MustCompile(`\b(?:\+?1[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}\b`), + Regex: regexp.MustCompile(`(?:\+?\b1[-.\s]?\(?|\(|\b)\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}\b`), Replacement: "[PHONE REDACTED]", }, "credit_card": { From 6b1e637efd5e0ddcb97951fb519ffe906ebede8e Mon Sep 17 00:00:00 2001 From: Wils Dawson Date: Mon, 28 Sep 2026 15:59:33 -0700 Subject: [PATCH 09/12] chore: gofmt advanced_server/ab_testing.go (PLT-3790) Formatting only. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../advanced_server/ab_testing.go | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/examples/contextual_access/advanced_server/ab_testing.go b/examples/contextual_access/advanced_server/ab_testing.go index 1580ae1..fbf4203 100644 --- a/examples/contextual_access/advanced_server/ab_testing.go +++ b/examples/contextual_access/advanced_server/ab_testing.go @@ -17,18 +17,18 @@ import ( // ABTestManager manages experiment state and variant assignment. type ABTestManager struct { mu sync.RWMutex - assignments map[string]string // "user:experiment" -> variant name + 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"` + 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. @@ -208,11 +208,11 @@ type RegistryResponse struct { // 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"` + 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. From 5b1e150613b58814a5a895c033316a72ec354d5d Mon Sep 17 00:00:00 2001 From: Wils Dawson Date: Mon, 28 Sep 2026 16:01:01 -0700 Subject: [PATCH 10/12] fix: expose port 8888 in the example Dockerfiles (PLT-3790) Every example server listens on -port 8888 by default, but the Dockerfiles exposed 8080. Co-Authored-By: Claude Opus 5.5 (1M context) --- examples/contextual_access/ab_testing/Dockerfile | 2 +- examples/contextual_access/advanced_server/Dockerfile | 2 +- examples/contextual_access/basic_rules/Dockerfile | 2 +- examples/contextual_access/content_filter/Dockerfile | 2 +- examples/contextual_access/pii_redactor/Dockerfile | 2 +- examples/contextual_access/user_blocking/Dockerfile | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/examples/contextual_access/ab_testing/Dockerfile b/examples/contextual_access/ab_testing/Dockerfile index 05fbcb1..d7ecf83 100644 --- a/examples/contextual_access/ab_testing/Dockerfile +++ b/examples/contextual_access/ab_testing/Dockerfile @@ -20,6 +20,6 @@ FROM gcr.io/distroless/static-debian12 COPY --from=builder /bin/server /bin/server -EXPOSE 8080 +EXPOSE 8888 ENTRYPOINT ["/bin/server"] diff --git a/examples/contextual_access/advanced_server/Dockerfile b/examples/contextual_access/advanced_server/Dockerfile index 530d3d5..d19195a 100644 --- a/examples/contextual_access/advanced_server/Dockerfile +++ b/examples/contextual_access/advanced_server/Dockerfile @@ -20,6 +20,6 @@ FROM gcr.io/distroless/static-debian12 COPY --from=builder /bin/server /bin/server -EXPOSE 8080 +EXPOSE 8888 ENTRYPOINT ["/bin/server"] diff --git a/examples/contextual_access/basic_rules/Dockerfile b/examples/contextual_access/basic_rules/Dockerfile index 91ab63d..49a2e14 100644 --- a/examples/contextual_access/basic_rules/Dockerfile +++ b/examples/contextual_access/basic_rules/Dockerfile @@ -20,6 +20,6 @@ FROM gcr.io/distroless/static-debian12 COPY --from=builder /bin/server /bin/server -EXPOSE 8080 +EXPOSE 8888 ENTRYPOINT ["/bin/server"] diff --git a/examples/contextual_access/content_filter/Dockerfile b/examples/contextual_access/content_filter/Dockerfile index b71d6ab..091d0f4 100644 --- a/examples/contextual_access/content_filter/Dockerfile +++ b/examples/contextual_access/content_filter/Dockerfile @@ -20,6 +20,6 @@ FROM gcr.io/distroless/static-debian12 COPY --from=builder /bin/server /bin/server -EXPOSE 8080 +EXPOSE 8888 ENTRYPOINT ["/bin/server"] diff --git a/examples/contextual_access/pii_redactor/Dockerfile b/examples/contextual_access/pii_redactor/Dockerfile index 314d3c8..7d79c3d 100644 --- a/examples/contextual_access/pii_redactor/Dockerfile +++ b/examples/contextual_access/pii_redactor/Dockerfile @@ -20,6 +20,6 @@ FROM gcr.io/distroless/static-debian12 COPY --from=builder /bin/server /bin/server -EXPOSE 8080 +EXPOSE 8888 ENTRYPOINT ["/bin/server"] diff --git a/examples/contextual_access/user_blocking/Dockerfile b/examples/contextual_access/user_blocking/Dockerfile index cc478bc..034f9f4 100644 --- a/examples/contextual_access/user_blocking/Dockerfile +++ b/examples/contextual_access/user_blocking/Dockerfile @@ -20,6 +20,6 @@ FROM gcr.io/distroless/static-debian12 COPY --from=builder /bin/server /bin/server -EXPOSE 8080 +EXPOSE 8888 ENTRYPOINT ["/bin/server"] From 503490856c2e8b181db9bf2f1d163ffd7328101a Mon Sep 17 00:00:00 2001 From: Wils Dawson Date: Mon, 28 Sep 2026 16:07:32 -0700 Subject: [PATCH 11/12] chore: point example -config flags at committed configs (PLT-3790) The READMEs, usage comments, and example config headers passed -config files that aren't in the repo (experiments.yaml, filter-rules.yaml, blocked-users.yaml, config.yaml), or a bare example-config.yaml that isn't found from the repository root. They now point at each example's committed example-config.yaml. The go run paths were fixed in 9849e78. Co-Authored-By: Claude Opus 5.5 (1M context) --- examples/contextual_access/ab_testing/README.md | 4 ++-- examples/contextual_access/ab_testing/main.go | 2 +- .../contextual_access/advanced_server/example-config.yaml | 2 +- examples/contextual_access/advanced_server/main.go | 2 +- examples/contextual_access/basic_rules/example-config.yaml | 2 +- examples/contextual_access/basic_rules/main.go | 2 +- examples/contextual_access/content_filter/README.md | 4 ++-- examples/contextual_access/content_filter/main.go | 2 +- examples/contextual_access/user_blocking/README.md | 2 +- examples/contextual_access/user_blocking/main.go | 2 +- 10 files changed, 12 insertions(+), 12 deletions(-) diff --git a/examples/contextual_access/ab_testing/README.md b/examples/contextual_access/ab_testing/README.md index 0d4df18..6cc2387 100644 --- a/examples/contextual_access/ab_testing/README.md +++ b/examples/contextual_access/ab_testing/README.md @@ -14,7 +14,7 @@ A minimal hook server that demonstrates how to **A/B test and canary-deploy tool ```bash # Run with experiment config -go run ./examples/contextual_access/ab_testing -config experiments.yaml +go run ./examples/contextual_access/ab_testing -config ./examples/contextual_access/ab_testing/example-config.yaml ``` ## Config File Format @@ -79,7 +79,7 @@ experiments: ```bash # Start with example config -go run ./examples/contextual_access/ab_testing -config experiments.yaml & +go run ./examples/contextual_access/ab_testing -config ./examples/contextual_access/ab_testing/example-config.yaml & # Send pre-hook requests for different users for i in $(seq 1 20); do diff --git a/examples/contextual_access/ab_testing/main.go b/examples/contextual_access/ab_testing/main.go index dd3e777..9d8b2ee 100644 --- a/examples/contextual_access/ab_testing/main.go +++ b/examples/contextual_access/ab_testing/main.go @@ -8,7 +8,7 @@ // // Usage: // -// go run ./examples/contextual_access/ab_testing -port 8888 -config experiments.yaml +// go run ./examples/contextual_access/ab_testing -port 8888 -config ./examples/contextual_access/ab_testing/example-config.yaml package main import ( diff --git a/examples/contextual_access/advanced_server/example-config.yaml b/examples/contextual_access/advanced_server/example-config.yaml index 305b3a3..19bcbca 100644 --- a/examples/contextual_access/advanced_server/example-config.yaml +++ b/examples/contextual_access/advanced_server/example-config.yaml @@ -3,7 +3,7 @@ # This file demonstrates all available configuration options. # The server hot-reloads this file when it changes. # -# Usage: go run ./examples/contextual_access/advanced_server -config example-config.yaml +# Usage: go run ./examples/contextual_access/advanced_server -config ./examples/contextual_access/advanced_server/example-config.yaml # Health endpoint configuration health: diff --git a/examples/contextual_access/advanced_server/main.go b/examples/contextual_access/advanced_server/main.go index d1c4262..573c05f 100644 --- a/examples/contextual_access/advanced_server/main.go +++ b/examples/contextual_access/advanced_server/main.go @@ -8,7 +8,7 @@ // // Usage: // -// go run ./examples/contextual_access/advanced_server -port 8888 -config config.yaml +// go run ./examples/contextual_access/advanced_server -port 8888 -config ./examples/contextual_access/advanced_server/example-config.yaml // go run ./examples/contextual_access/advanced_server -port 8888 -token secret123 package main diff --git a/examples/contextual_access/basic_rules/example-config.yaml b/examples/contextual_access/basic_rules/example-config.yaml index 04d424b..318708f 100644 --- a/examples/contextual_access/basic_rules/example-config.yaml +++ b/examples/contextual_access/basic_rules/example-config.yaml @@ -3,7 +3,7 @@ # This file demonstrates all available configuration options. # The server hot-reloads this file when it changes. # -# Usage: go run ./examples/contextual_access/basic_rules -config example-config.yaml +# Usage: go run ./examples/contextual_access/basic_rules -config ./examples/contextual_access/basic_rules/example-config.yaml # Health endpoint configuration health: diff --git a/examples/contextual_access/basic_rules/main.go b/examples/contextual_access/basic_rules/main.go index b8da640..2de7568 100644 --- a/examples/contextual_access/basic_rules/main.go +++ b/examples/contextual_access/basic_rules/main.go @@ -4,7 +4,7 @@ // Usage: // // # Basic HTTP server with bearer token auth -// go run ./examples/contextual_access/basic_rules -port 8888 -token secret123 -config config.yaml +// go run ./examples/contextual_access/basic_rules -port 8888 -token secret123 -config ./examples/contextual_access/basic_rules/example-config.yaml // // # HTTPS server (TLS) // go run ./examples/contextual_access/basic_rules -port 8888 -tls -cert server.crt -key server.key diff --git a/examples/contextual_access/content_filter/README.md b/examples/contextual_access/content_filter/README.md index 80e6b72..2c0bb22 100644 --- a/examples/contextual_access/content_filter/README.md +++ b/examples/contextual_access/content_filter/README.md @@ -12,7 +12,7 @@ A minimal hook server that demonstrates how to **filter tool calls and responses ```bash # Run with a config file -go run ./examples/contextual_access/content_filter -config filter-rules.yaml +go run ./examples/contextual_access/content_filter -config ./examples/contextual_access/content_filter/example-config.yaml ``` ## Config File Format @@ -73,7 +73,7 @@ blocked_output_patterns: ```bash # Start the server with example rules -go run ./examples/contextual_access/content_filter -config filter-rules.yaml & +go run ./examples/contextual_access/content_filter -config ./examples/contextual_access/content_filter/example-config.yaml & # Test pre-hook - should be blocked (contains blocked keyword) curl -X POST http://localhost:8888/pre \ diff --git a/examples/contextual_access/content_filter/main.go b/examples/contextual_access/content_filter/main.go index 639f60f..467cc48 100644 --- a/examples/contextual_access/content_filter/main.go +++ b/examples/contextual_access/content_filter/main.go @@ -8,7 +8,7 @@ // Usage: // // go run ./examples/contextual_access/content_filter -port 8888 -// go run ./examples/contextual_access/content_filter -port 8888 -config filter-rules.yaml +// go run ./examples/contextual_access/content_filter -port 8888 -config ./examples/contextual_access/content_filter/example-config.yaml package main import ( diff --git a/examples/contextual_access/user_blocking/README.md b/examples/contextual_access/user_blocking/README.md index a480720..edad216 100644 --- a/examples/contextual_access/user_blocking/README.md +++ b/examples/contextual_access/user_blocking/README.md @@ -15,7 +15,7 @@ A minimal hook server that demonstrates how to **block specific users** from acc go run ./examples/contextual_access/user_blocking -block "user1,user2,user3" # Block users via config file -go run ./examples/contextual_access/user_blocking -config blocked-users.yaml +go run ./examples/contextual_access/user_blocking -config ./examples/contextual_access/user_blocking/example-config.yaml ``` ## Config File Format diff --git a/examples/contextual_access/user_blocking/main.go b/examples/contextual_access/user_blocking/main.go index cf3cfd8..8eed7b7 100644 --- a/examples/contextual_access/user_blocking/main.go +++ b/examples/contextual_access/user_blocking/main.go @@ -7,7 +7,7 @@ // // Usage: // -// go run ./examples/contextual_access/user_blocking -port 8888 -config blocked-users.yaml +// go run ./examples/contextual_access/user_blocking -port 8888 -config ./examples/contextual_access/user_blocking/example-config.yaml // go run ./examples/contextual_access/user_blocking -port 8888 -block "user1,user2,user3" package main From 7782bdfb5d34083c95839bbbe6bf82fc2faaeed5 Mon Sep 17 00:00:00 2001 From: Wils Dawson Date: Mon, 28 Sep 2026 16:11:56 -0700 Subject: [PATCH 12/12] docs: note per-field matching in content_filter (PLT-3790) Say in the README that rules match each value on its own, so a keyword or pattern doesn't match across two separate fields. The pre-hook began matching per field in ee0025f, and the post-hook in 8aaec30. Co-Authored-By: Claude Opus 5.5 (1M context) --- examples/contextual_access/content_filter/README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/examples/contextual_access/content_filter/README.md b/examples/contextual_access/content_filter/README.md index 2c0bb22..7095b80 100644 --- a/examples/contextual_access/content_filter/README.md +++ b/examples/contextual_access/content_filter/README.md @@ -56,6 +56,8 @@ blocked_output_patterns: ## How It Works +Rules match each value on its own, so a keyword or pattern doesn't match across two separate fields. + ### Input Filtering (Pre-Hook) 1. Each tool input value is checked on its own 2. Blocked keywords are checked (case-insensitive substring match)