fix(wing,server): client names are matched as whole Unicode words; unsafe lists are a startup error (review of #214)
CI / Lint / Test / Vet (pull_request) Successful in 1m3s
CI / Mirror to GitHub (pull_request) Skipped

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-10-20 21:44:23 +02:00
co-authored by Claude Sonnet 5.5
parent 0582dfdf6b
commit 80f3fd3c5e
5 changed files with 120 additions and 43 deletions
+28
View File
@@ -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
}
+56
View File
@@ -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")
})
}
}