From 0582dfdf6bc99a9c7a8ee63dc44ab96f7bde66d5 Mon Sep 17 00:00:00 2001 From: Mathias Date: Tue, 20 Oct 2026 21:39:37 +0200 Subject: [PATCH 1/2] fix(server): wrap the shared client-name alternation like claudewatcher does (#209) The deployment injects CLAUDE_INGEST_CLIENT_BLOCK as a bare alternation of client names; claudewatcher compiles it as (?i)\b(...)\b. The raw/ router used it raw, so a short client name would match inside ordinary words and park clips that have nothing to do with the client. INGEST_CLIENT_BLOCK stays a full regexp, used as is. Mutation-checked. Co-Authored-By: Claude Sonnet 5.5 --- ingestion/cmd/server/main.go | 20 ++++++++++++---- ingestion/cmd/server/main_test.go | 39 +++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 4 deletions(-) diff --git a/ingestion/cmd/server/main.go b/ingestion/cmd/server/main.go index d33af07..36af2c6 100644 --- a/ingestion/cmd/server/main.go +++ b/ingestion/cmd/server/main.go @@ -150,6 +150,21 @@ func envInt(key string, fallback int) int { return fallback } +// ingestClientPattern picks the client-content pattern for raw/ routing. +// INGEST_CLIENT_BLOCK is a complete regexp and is used as is. Otherwise the +// shared CLAUDE_INGEST_CLIENT_BLOCK is used, which the deployment injects as a +// bare alternation of client names that claudewatcher wraps in \b(...)\b: it is +// wrapped the same way here, or a short name would match inside ordinary words. +func ingestClientPattern(getenv func(string) string) string { + if p := getenv("INGEST_CLIENT_BLOCK"); p != "" { + return p + } + if names := getenv("CLAUDE_INGEST_CLIENT_BLOCK"); names != "" { + return `\b(` + names + `)\b` + } + return "" +} + // envFlag reads a boolean switch. Unset is off; a value strconv.ParseBool does // not understand is an error, so a typo ("ture") cannot silently leave a // safety-relevant switch off. @@ -426,10 +441,7 @@ func main() { os.Exit(1) } if routing { - pattern := os.Getenv("INGEST_CLIENT_BLOCK") - if pattern == "" { - pattern = os.Getenv("CLAUDE_INGEST_CLIENT_BLOCK") - } + pattern := ingestClientPattern(os.Getenv) router, rerr := wing.NewRouter(brainDir, envOr("INGEST_DEFAULT_WING", "web-clip"), pattern) if rerr != nil { logger.Error("build wing router", "err", rerr) diff --git a/ingestion/cmd/server/main_test.go b/ingestion/cmd/server/main_test.go index 24f7bb2..33410f0 100644 --- a/ingestion/cmd/server/main_test.go +++ b/ingestion/cmd/server/main_test.go @@ -3,6 +3,7 @@ package main import ( "context" + "github.com/mathiasbq/hyperguild/ingestion/internal/wing" "log/slog" "os" "path/filepath" @@ -129,3 +130,41 @@ func TestEnvFlag(t *testing.T) { }) } } + +// The deployment injects CLAUDE_INGEST_CLIENT_BLOCK as an ALTERNATION OF CLIENT +// NAMES that claudewatcher wraps in \b(...)\b. Used raw, a short name would match +// inside ordinary words and park half the clips. +func TestIngestClientPattern(t *testing.T) { + get := func(m map[string]string) func(string) string { return func(k string) string { return m[k] } } + tests := []struct { + name string + env map[string]string + want string + }{ + {"nothing set", map[string]string{}, ""}, + {"explicit pattern is a full regexp, used as is", map[string]string{"INGEST_CLIENT_BLOCK": `acme\s+corp`, "CLAUDE_INGEST_CLIENT_BLOCK": "other"}, `acme\s+corp`}, + {"the shared client-name alternation is wrapped like claudewatcher wraps it", map[string]string{"CLAUDE_INGEST_CLIENT_BLOCK": "dif|trucker"}, `\b(dif|trucker)\b`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, ingestClientPattern(get(tt.env))) + }) + } +} + +func TestIngestClientPattern_AShortClientNameDoesNotParkOrdinaryWords(t *testing.T) { + pattern := ingestClientPattern(func(k string) string { + if k == "CLAUDE_INGEST_CLIENT_BLOCK" { + return "dif|trucker" + } + return "" + }) + r, err := wing.NewRouter(t.TempDir(), "web-clip", pattern) + require.NoError(t, err) + d := r.Resolve(context.Background(), wing.Note{Text: "a different approach to modifying prompts"}) + assert.Equal(t, wing.Default, d.Source, "'dif' inside 'different' and 'modifying' is not the client") + d = r.Resolve(context.Background(), wing.Note{Text: "kickoff for the DIF project next week"}) + assert.Equal(t, wing.Exception, d.Source) + d = r.Resolve(context.Background(), wing.Note{Text: "Trucker, the client, asked for"}) + assert.Equal(t, wing.Exception, d.Source) +} -- 2.54.0 From 80f3fd3c5eaaaf8aba04d291ceaff5b1a34d3f31 Mon Sep 17 00:00:00 2001 From: Mathias Date: Tue, 20 Oct 2026 21:44:23 +0200 Subject: [PATCH 2/2] fix(wing,server): client names are matched as whole Unicode words; unsafe lists are a startup error (review of #214) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - WrapNames bounds the bare name list with Unicode-aware edges instead of \b (Go's \b is ASCII-only, so a name with a non-ASCII edge letter such as Åke, Société, Ørsted would never have matched), and validates the list on its own: it must compile, must not match the empty string, and cannot break out of its group ('x)|.*(' compiles alone only if balanced) - NewRouter refuses any pattern that matches text naming no client - docs state the whole-word semantics and what is not matched Mutation-checked. Open decision for Mathias: whole-word versus substring recall for a confidentiality filter (plurals, CamelCase and digits glued on do not match). Co-Authored-By: Claude Sonnet 5.5 --- docs/raw-inbox.md | 2 +- ingestion/cmd/server/main.go | 21 ++++++---- ingestion/cmd/server/main_test.go | 56 ++++++++++---------------- ingestion/internal/wing/router.go | 28 +++++++++++++ ingestion/internal/wing/router_test.go | 56 ++++++++++++++++++++++++++ 5 files changed, 120 insertions(+), 43 deletions(-) diff --git a/docs/raw-inbox.md b/docs/raw-inbox.md index c2c8adf..eac4be9 100644 --- a/docs/raw-inbox.md +++ b/docs/raw-inbox.md @@ -33,7 +33,7 @@ deployment: |---|---| | `INGEST_WING_ROUTING` | `1`/`true` enables the rules above; unset or `0`/`false` is off; any other value stops the server at startup | | `INGEST_DEFAULT_WING` | wing for unsignalled notes (default `web-clip`; must be a plain name, not `sources`/`concepts`/`entities`/`wiki`) | -| `INGEST_CLIENT_BLOCK` | case-insensitive regexp over note text; a match parks an unsignalled note. Falls back to `CLAUDE_INGEST_CLIENT_BLOCK`. Matched after folding fullwidth letters, removing zero-width characters, normalising dashes and removing whitespace. Patterns with spaces, `\\s` or `\\b` work (whitespace is collapsed in one view and removed in another). It catches what it names and nothing else: leetspeak, punctuation splits (`a.c.m.e`), encodings and homoglyphs outside a small Cyrillic/Greek table are not caught. Large notes are checked in 1 MiB chunks | +| `INGEST_CLIENT_BLOCK` | case-insensitive regexp over note text; a match parks an unsignalled note. Falls back to `CLAUDE_INGEST_CLIENT_BLOCK`, which the deployment injects as a bare list of client names (`acme|globex`): it is then matched as whole names, with word edges decided by Unicode letters and digits (an underscore or punctuation is a boundary, so `trucker_ecommerce` matches `trucker`; `acmecorp`, `acme2` and plurals do not), and a list that is not a valid regexp, has an empty alternative, or could break out of its group stops the server at startup. A full pattern that matches the empty string is refused at startup too. Matched after folding fullwidth letters, removing zero-width characters, normalising dashes and removing whitespace. Patterns with spaces, `\\s` or `\\b` work (whitespace is collapsed in one view and removed in another). It catches what it names and nothing else: leetspeak, punctuation splits (`a.c.m.e`), encodings and homoglyphs outside a small Cyrillic/Greek table are not caught. Large notes are checked in 1 MiB chunks | A bad default or pattern stops the server at startup. A wing created while the server runs is usable without a restart. diff --git a/ingestion/cmd/server/main.go b/ingestion/cmd/server/main.go index 36af2c6..bfa7057 100644 --- a/ingestion/cmd/server/main.go +++ b/ingestion/cmd/server/main.go @@ -152,17 +152,18 @@ func envInt(key string, fallback int) int { // ingestClientPattern picks the client-content pattern for raw/ routing. // INGEST_CLIENT_BLOCK is a complete regexp and is used as is. Otherwise the -// shared CLAUDE_INGEST_CLIENT_BLOCK is used, which the deployment injects as a -// bare alternation of client names that claudewatcher wraps in \b(...)\b: it is -// wrapped the same way here, or a short name would match inside ordinary words. -func ingestClientPattern(getenv func(string) string) string { +// shared CLAUDE_INGEST_CLIENT_BLOCK is used: the deployment injects it as a bare +// list of client names (regexp alternation), which wing.WrapNames bounds so a +// short name does not match inside ordinary words, and which it refuses if it +// would break out of its group or match the empty string. +func ingestClientPattern(getenv func(string) string) (string, error) { if p := getenv("INGEST_CLIENT_BLOCK"); p != "" { - return p + return p, nil } if names := getenv("CLAUDE_INGEST_CLIENT_BLOCK"); names != "" { - return `\b(` + names + `)\b` + return wing.WrapNames(names) } - return "" + return "", nil } // envFlag reads a boolean switch. Unset is off; a value strconv.ParseBool does @@ -441,7 +442,11 @@ func main() { os.Exit(1) } if routing { - pattern := ingestClientPattern(os.Getenv) + pattern, perr := ingestClientPattern(os.Getenv) + if perr != nil { + logger.Error("client-content pattern", "err", perr) + os.Exit(1) + } router, rerr := wing.NewRouter(brainDir, envOr("INGEST_DEFAULT_WING", "web-clip"), pattern) if rerr != nil { logger.Error("build wing router", "err", rerr) diff --git a/ingestion/cmd/server/main_test.go b/ingestion/cmd/server/main_test.go index 33410f0..395f576 100644 --- a/ingestion/cmd/server/main_test.go +++ b/ingestion/cmd/server/main_test.go @@ -3,7 +3,6 @@ package main import ( "context" - "github.com/mathiasbq/hyperguild/ingestion/internal/wing" "log/slog" "os" "path/filepath" @@ -131,40 +130,29 @@ func TestEnvFlag(t *testing.T) { } } -// The deployment injects CLAUDE_INGEST_CLIENT_BLOCK as an ALTERNATION OF CLIENT -// NAMES that claudewatcher wraps in \b(...)\b. Used raw, a short name would match -// inside ordinary words and park half the clips. +// The deployment injects CLAUDE_INGEST_CLIENT_BLOCK as a bare list of client +// names (regexp alternation). Used raw, a short name would match inside ordinary +// words; wrapped with \b, a name with a non-ASCII edge letter would never match. func TestIngestClientPattern(t *testing.T) { get := func(m map[string]string) func(string) string { return func(k string) string { return m[k] } } - tests := []struct { - name string - env map[string]string - want string - }{ - {"nothing set", map[string]string{}, ""}, - {"explicit pattern is a full regexp, used as is", map[string]string{"INGEST_CLIENT_BLOCK": `acme\s+corp`, "CLAUDE_INGEST_CLIENT_BLOCK": "other"}, `acme\s+corp`}, - {"the shared client-name alternation is wrapped like claudewatcher wraps it", map[string]string{"CLAUDE_INGEST_CLIENT_BLOCK": "dif|trucker"}, `\b(dif|trucker)\b`}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - assert.Equal(t, tt.want, ingestClientPattern(get(tt.env))) - }) - } -} - -func TestIngestClientPattern_AShortClientNameDoesNotParkOrdinaryWords(t *testing.T) { - pattern := ingestClientPattern(func(k string) string { - if k == "CLAUDE_INGEST_CLIENT_BLOCK" { - return "dif|trucker" - } - return "" + t.Run("nothing set", func(t *testing.T) { + p, err := ingestClientPattern(get(map[string]string{})) + assert.NoError(t, err) + assert.Empty(t, p) + }) + t.Run("explicit pattern is a full regexp, used as is", func(t *testing.T) { + p, err := ingestClientPattern(get(map[string]string{"INGEST_CLIENT_BLOCK": `acme\s+corp`, "CLAUDE_INGEST_CLIENT_BLOCK": "other"})) + assert.NoError(t, err) + assert.Equal(t, `acme\s+corp`, p) + }) + t.Run("the shared names are wrapped", func(t *testing.T) { + p, err := ingestClientPattern(get(map[string]string{"CLAUDE_INGEST_CLIENT_BLOCK": "dif|trucker"})) + assert.NoError(t, err) + assert.Contains(t, p, "dif|trucker") + assert.NotEqual(t, "dif|trucker", p) + }) + t.Run("a shared value that would break out of the group is a startup error", func(t *testing.T) { + _, err := ingestClientPattern(get(map[string]string{"CLAUDE_INGEST_CLIENT_BLOCK": "x)|.*("})) + assert.Error(t, err) }) - r, err := wing.NewRouter(t.TempDir(), "web-clip", pattern) - require.NoError(t, err) - d := r.Resolve(context.Background(), wing.Note{Text: "a different approach to modifying prompts"}) - assert.Equal(t, wing.Default, d.Source, "'dif' inside 'different' and 'modifying' is not the client") - d = r.Resolve(context.Background(), wing.Note{Text: "kickoff for the DIF project next week"}) - assert.Equal(t, wing.Exception, d.Source) - d = r.Resolve(context.Background(), wing.Note{Text: "Trucker, the client, asked for"}) - assert.Equal(t, wing.Exception, d.Source) } diff --git a/ingestion/internal/wing/router.go b/ingestion/internal/wing/router.go index 3df65ed..6fd03dc 100644 --- a/ingestion/internal/wing/router.go +++ b/ingestion/internal/wing/router.go @@ -94,6 +94,9 @@ func NewRouter(brainDir, defaultWing, clientPattern string) (Router, error) { if err != nil { return Router{}, fmt.Errorf("client-content pattern: %w", err) } + if re.MatchString("x") || re.MatchString("") { + return Router{}, fmt.Errorf("client-content pattern %q matches text that names no client; it would park every note", clientPattern) + } r.ClientBlock = re } return r, nil @@ -218,3 +221,28 @@ var latinLookalike = map[rune]rune{ '\u0410': 'A', '\u0412': 'B', '\u0415': 'E', '\u041a': 'K', '\u041c': 'M', '\u041d': 'H', '\u041e': 'O', '\u0420': 'P', '\u0421': 'C', '\u0422': 'T', '\u0425': 'X', '\u03bf': 'o', '\u03bd': 'v', '\u03c1': 'p', '\u0391': 'A', '\u0392': 'B', '\u0395': 'E', '\u039f': 'O', '\u03a1': 'P', } + +// WrapNames turns a bare list of client names (a regexp alternation such as +// "acme|globex") into a pattern that matches a name only as a whole word, with +// word edges decided by Unicode letters and digits: Go's \b is ASCII-only, so +// with it a name that begins or ends in a letter like Å, é or Ø would never +// match. An underscore or punctuation is a boundary (trucker_ecommerce matches +// "trucker"), a letter or digit glued on is not (acmecorp, acme2). +// +// The list is validated first, on its own: it must compile as a regexp, must not +// match the empty string (an empty alternative such as "a||b" would match +// everywhere), and must be self-contained: a value like "x)|.*(" compiles alone +// only if it is balanced, so it cannot break out of the group it is put in. +func WrapNames(names string) (string, error) { + if strings.TrimSpace(names) == "" { + return "", fmt.Errorf("client names list is empty") + } + re, err := regexp.Compile("(?i)" + names) + if err != nil { + return "", fmt.Errorf("client names are not a valid regexp alternation: %w", err) + } + if re.MatchString("") { + return "", fmt.Errorf("client names contain an empty alternative; it would match everywhere") + } + return `(?:^|[^\p{L}\p{N}])(?:` + names + `)(?:[^\p{L}\p{N}]|$)`, nil +} diff --git a/ingestion/internal/wing/router_test.go b/ingestion/internal/wing/router_test.go index 384192f..a441fbb 100644 --- a/ingestion/internal/wing/router_test.go +++ b/ingestion/internal/wing/router_test.go @@ -234,3 +234,59 @@ func TestRouter_ClientMarkerChunkingDoesNotSplitAMultiByteRune(t *testing.T) { clean := strings.Repeat("\u00e9", 300) assert.Equal(t, wing.Default, r.Resolve(context.Background(), wing.Note{Text: clean}).Source, "no panic or false match on a text of 2-byte runes") } + +// From the review of #214: the shared secret is a bare list of client names. +func TestWrapNames(t *testing.T) { + park := func(t *testing.T, names, text string) bool { + t.Helper() + pat, err := wing.WrapNames(names) + assert.NoError(t, err) + r, err := wing.NewRouter(routerBrain(t), "web-clip", pat) + assert.NoError(t, err) + return r.Resolve(context.Background(), wing.Note{Text: text}).Source == wing.Exception + } + t.Run("a short name does not match inside ordinary words", func(t *testing.T) { + assert.False(t, park(t, "dif|trucker", "a different approach to modifying prompts")) + assert.True(t, park(t, "dif|trucker", "kickoff for the DIF project")) + }) + t.Run("names with a non-ASCII letter at the edge still match (Go's \\b would never)", func(t *testing.T) { + assert.True(t, park(t, "Åke|Société|Ørsted", "Åke called")) + assert.True(t, park(t, "Åke|Société|Ørsted", "la Société wrote")) + assert.True(t, park(t, "Åke|Société|Ørsted", "Ørsted wind")) + }) + t.Run("identifiers with underscores and punctuation count as boundaries", func(t *testing.T) { + assert.True(t, park(t, "trucker", "repo trucker_ecommerce was cloned")) + assert.True(t, park(t, "trucker", "(Trucker)")) + }) + t.Run("a name inside a longer word or with digits glued on is not a boundary", func(t *testing.T) { + assert.False(t, park(t, "acme", "acmecorp")) + assert.False(t, park(t, "acme", "acme2")) + }) +} + +func TestWrapNames_RefusesWhatWouldBreakOrBlockEverything(t *testing.T) { + for _, bad := range []string{ + "x)|.*(", // breaks out of the group + "acme)|(b", // changes the structure + "a||b", // empty alternative matches everywhere + "acme|", // trailing empty alternative + "|acme", // leading empty alternative + "", // nothing + "c++ corp", // not a valid regexp + "([unclosed", // not a valid regexp + } { + t.Run(bad, func(t *testing.T) { + _, err := wing.WrapNames(bad) + assert.Error(t, err) + }) + } +} + +func TestNewRouter_RefusesAPatternThatMatchesTheEmptyString(t *testing.T) { + for _, p := range []string{`.*`, `x*`, `^`, `\b`, `(a|)`} { + t.Run(p, func(t *testing.T) { + _, err := wing.NewRouter(routerBrain(t), "web-clip", p) + assert.Error(t, err, "it would park every note") + }) + } +} -- 2.54.0