fix(wing,server): client names are matched as whole Unicode words; unsafe lists are a startup error (review of #214)
- 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:
+1
-1
@@ -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_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_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
|
A bad default or pattern stops the server at startup. A wing created while the
|
||||||
server runs is usable without a restart.
|
server runs is usable without a restart.
|
||||||
|
|||||||
@@ -152,17 +152,18 @@ func envInt(key string, fallback int) int {
|
|||||||
|
|
||||||
// ingestClientPattern picks the client-content pattern for raw/ routing.
|
// ingestClientPattern picks the client-content pattern for raw/ routing.
|
||||||
// INGEST_CLIENT_BLOCK is a complete regexp and is used as is. Otherwise the
|
// 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
|
// shared CLAUDE_INGEST_CLIENT_BLOCK is used: the deployment injects it as a bare
|
||||||
// bare alternation of client names that claudewatcher wraps in \b(...)\b: it is
|
// list of client names (regexp alternation), which wing.WrapNames bounds so a
|
||||||
// wrapped the same way here, or a short name would match inside ordinary words.
|
// short name does not match inside ordinary words, and which it refuses if it
|
||||||
func ingestClientPattern(getenv func(string) string) string {
|
// 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 != "" {
|
if p := getenv("INGEST_CLIENT_BLOCK"); p != "" {
|
||||||
return p
|
return p, nil
|
||||||
}
|
}
|
||||||
if names := getenv("CLAUDE_INGEST_CLIENT_BLOCK"); names != "" {
|
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
|
// envFlag reads a boolean switch. Unset is off; a value strconv.ParseBool does
|
||||||
@@ -441,7 +442,11 @@ func main() {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
if routing {
|
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)
|
router, rerr := wing.NewRouter(brainDir, envOr("INGEST_DEFAULT_WING", "web-clip"), pattern)
|
||||||
if rerr != nil {
|
if rerr != nil {
|
||||||
logger.Error("build wing router", "err", rerr)
|
logger.Error("build wing router", "err", rerr)
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/wing"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -131,40 +130,29 @@ func TestEnvFlag(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The deployment injects CLAUDE_INGEST_CLIENT_BLOCK as an ALTERNATION OF CLIENT
|
// The deployment injects CLAUDE_INGEST_CLIENT_BLOCK as a bare list of client
|
||||||
// NAMES that claudewatcher wraps in \b(...)\b. Used raw, a short name would match
|
// names (regexp alternation). Used raw, a short name would match inside ordinary
|
||||||
// inside ordinary words and park half the clips.
|
// words; wrapped with \b, a name with a non-ASCII edge letter would never match.
|
||||||
func TestIngestClientPattern(t *testing.T) {
|
func TestIngestClientPattern(t *testing.T) {
|
||||||
get := func(m map[string]string) func(string) string { return func(k string) string { return m[k] } }
|
get := func(m map[string]string) func(string) string { return func(k string) string { return m[k] } }
|
||||||
tests := []struct {
|
t.Run("nothing set", func(t *testing.T) {
|
||||||
name string
|
p, err := ingestClientPattern(get(map[string]string{}))
|
||||||
env map[string]string
|
assert.NoError(t, err)
|
||||||
want string
|
assert.Empty(t, p)
|
||||||
}{
|
|
||||||
{"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)))
|
|
||||||
})
|
})
|
||||||
}
|
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)
|
||||||
func TestIngestClientPattern_AShortClientNameDoesNotParkOrdinaryWords(t *testing.T) {
|
assert.Equal(t, `acme\s+corp`, p)
|
||||||
pattern := ingestClientPattern(func(k string) string {
|
})
|
||||||
if k == "CLAUDE_INGEST_CLIENT_BLOCK" {
|
t.Run("the shared names are wrapped", func(t *testing.T) {
|
||||||
return "dif|trucker"
|
p, err := ingestClientPattern(get(map[string]string{"CLAUDE_INGEST_CLIENT_BLOCK": "dif|trucker"}))
|
||||||
}
|
assert.NoError(t, err)
|
||||||
return ""
|
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)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,6 +94,9 @@ func NewRouter(brainDir, defaultWing, clientPattern string) (Router, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return Router{}, fmt.Errorf("client-content pattern: %w", err)
|
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
|
r.ClientBlock = re
|
||||||
}
|
}
|
||||||
return r, nil
|
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',
|
'\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',
|
'\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
|
||||||
|
}
|
||||||
|
|||||||
@@ -234,3 +234,59 @@ func TestRouter_ClientMarkerChunkingDoesNotSplitAMultiByteRune(t *testing.T) {
|
|||||||
clean := strings.Repeat("\u00e9", 300)
|
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")
|
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")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user