From f6f03e4de99c472aa361ef3a71a8cdfb5d70460e Mon Sep 17 00:00:00 2001 From: sususu Date: Fri, 14 Aug 2026 16:23:50 +0800 Subject: [PATCH] fix(claude): use BIP-39 words for OAuth MCP tool aliases Replace high-entropy Base32 alias IDs with request-local BIP-39 English words so weaker models are less likely to drift tool names. Keep a two-word virtual server plus one-word tool ID, linearly probe wordlist space on collision without self-overlap, and fall through to unambiguous longest semantic-suffix recovery after a successful but wrong parse. Fixes #4916 --- .../executor/claude_executor_request.go | 80 +- .../claude_executor_request_remap_test.go | 93 + .../executor/helps/claude_bip39_words.txt | 2048 +++++++++++++++++ .../executor/helps/claude_mcp_alias.go | 77 +- .../executor/helps/claude_mcp_alias_test.go | 147 +- .../helps/claude_mcp_alias_wordlist.go | 12 + 6 files changed, 2398 insertions(+), 59 deletions(-) create mode 100644 internal/runtime/executor/helps/claude_bip39_words.txt create mode 100644 internal/runtime/executor/helps/claude_mcp_alias_wordlist.go diff --git a/internal/runtime/executor/claude_executor_request.go b/internal/runtime/executor/claude_executor_request.go index 32bb301a4..9214015bc 100644 --- a/internal/runtime/executor/claude_executor_request.go +++ b/internal/runtime/executor/claude_executor_request.go @@ -1100,15 +1100,12 @@ func remapOAuthToolNamesWithBatchedEdits(body []byte, mcpAliases claudeMCPAliasO if _, exists := forwardMap[name]; exists { return true } - for attempt := uint32(0); ; attempt++ { - alias := helps.ClaudeMCPToolAlias(mcpAliases.secret, name, attempt) - if reservedNames[alias] { - continue - } - forwardMap[name] = alias - reservedNames[alias] = true - break + alias, allocated := helps.AllocateClaudeMCPToolAlias(mcpAliases.secret, name, reservedNames) + if !allocated { + return true } + forwardMap[name] = alias + reservedNames[alias] = true return true }) } @@ -1354,15 +1351,12 @@ func remapOAuthToolNamesWithOptionsLegacy(body []byte, mcpAliases claudeMCPAlias if _, exists := forwardMap[name]; exists { return true } - for attempt := uint32(0); ; attempt++ { - alias := helps.ClaudeMCPToolAlias(mcpAliases.secret, name, attempt) - if reservedNames[alias] { - continue - } - forwardMap[name] = alias - reservedNames[alias] = true - break + alias, allocated := helps.AllocateClaudeMCPToolAlias(mcpAliases.secret, name, reservedNames) + if !allocated { + return true } + forwardMap[name] = alias + reservedNames[alias] = true return true }) } @@ -1537,33 +1531,24 @@ func newClaudeMCPAliasResolver(reverseMap map[string]string) claudeMCPAliasResol } func parseClaudeMCPAlias(name string) (claudeMCPAliasParts, bool) { + if !helps.IsClaudeMCPToolName(name) { + return claudeMCPAliasParts{}, false + } rest, ok := strings.CutPrefix(name, "mcp__") if !ok { return claudeMCPAliasParts{}, false } server, tool, ok := strings.Cut(rest, "__") - if !ok || !isClaudeMCPAliasDigest(server) { + if !ok || server == "" { return claudeMCPAliasParts{}, false } toolID, semantic, ok := strings.Cut(tool, "_") - if !ok || !isClaudeMCPAliasDigest(toolID) || semantic == "" { + if !ok || toolID == "" || semantic == "" { return claudeMCPAliasParts{}, false } return claudeMCPAliasParts{server: server, toolID: toolID, semantic: semantic}, true } -func isClaudeMCPAliasDigest(value string) bool { - if len(value) != 12 { - return false - } - for _, char := range value { - if (char < 'a' || char > 'z') && (char < '2' || char > '7') { - return false - } - } - return true -} - func claudeMCPAliasServer(name string) string { rest, ok := strings.CutPrefix(name, "mcp__") if !ok { @@ -1624,13 +1609,38 @@ func (resolver claudeMCPAliasResolver) resolve(name string) (string, bool, error matchCount++ } } - } else { - // Keep generated aliases strict while allowing a malformed response tool ID - // to recover only when its request-local semantic suffix is unambiguous. + } + // Extra words in the tool component still parse, but the semantic field + // is then wrong. Fall through to an unambiguous suffix match so word-level + // repeats do not become restore 500s. + if matchCount == 0 { + var suffixMatches []claudeMCPAliasEntry for _, entry := range resolver.aliases { if entry.parts.server == server && strings.HasSuffix(normalizedName, "_"+entry.parts.semantic) { - matchedOriginal = entry.original - matchCount++ + suffixMatches = append(suffixMatches, entry) + } + } + if len(suffixMatches) == 1 { + matchedOriginal = suffixMatches[0].original + matchCount = 1 + } else if len(suffixMatches) > 1 { + // If multiple candidates match (e.g. "_file" and "_read_file"), + // choose the strictly longest semantic match when unambiguous. + longest := suffixMatches[0] + tie := false + for _, candidate := range suffixMatches[1:] { + if len(candidate.parts.semantic) > len(longest.parts.semantic) { + longest = candidate + tie = false + } else if len(candidate.parts.semantic) == len(longest.parts.semantic) { + tie = true + } + } + if !tie { + matchedOriginal = longest.original + matchCount = 1 + } else { + matchCount = len(suffixMatches) } } } diff --git a/internal/runtime/executor/claude_executor_request_remap_test.go b/internal/runtime/executor/claude_executor_request_remap_test.go index 390fe978a..63df53aec 100644 --- a/internal/runtime/executor/claude_executor_request_remap_test.go +++ b/internal/runtime/executor/claude_executor_request_remap_test.go @@ -243,6 +243,61 @@ func TestReverseRemapOAuthToolNamesRecoversMalformedToolIDBySemanticSuffix(t *te } } +func TestReverseRemapOAuthToolNamesWithBIP39Aliases(t *testing.T) { + body := []byte(`{"tools":[{"name":"Bash","input_schema":{"type":"object"}},{"name":"fetch_url","input_schema":{"type":"object"}}]}`) + remapped, reverseMap := remapOAuthToolNamesWithOptions(body, claudeMCPAliasOptions{secret: "bip39-caller"}) + bashAlias := gjson.GetBytes(remapped, "tools.0.name").String() + fetchAlias := gjson.GetBytes(remapped, "tools.1.name").String() + + if !helps.IsClaudeMCPToolName(bashAlias) { + t.Fatalf("generated bash alias is invalid: %q", bashAlias) + } + if !helps.IsClaudeMCPToolName(fetchAlias) { + t.Fatalf("generated fetch alias is invalid: %q", fetchAlias) + } + + bashParts, ok := parseClaudeMCPAlias(bashAlias) + if !ok { + t.Fatalf("parseClaudeMCPAlias(%q) failed", bashAlias) + } + if bashParts.semantic != "Bash" { + t.Fatalf("bashParts.semantic = %q, want Bash", bashParts.semantic) + } + + repeatedAlias := "mcp__" + bashParts.server + "__" + bashParts.server + "__" + bashParts.toolID + "_Bash" + mangledToolIDAlias := "mcp__" + bashParts.server + "__corruptedword_Bash" + repeatedToolIDAlias := "mcp__" + bashParts.server + "__" + bashParts.toolID + "_" + bashParts.toolID + "_Bash" + extraWordAlias := "mcp__" + bashParts.server + "__" + bashParts.toolID + "_cabin_Bash" + + response := []byte(fmt.Sprintf(`{"content":[ + {"type":"tool_use","id":"toolu_1","name":%q,"input":{}}, + {"type":"tool_use","id":"toolu_2","name":%q,"input":{}}, + {"type":"tool_reference","tool_name":%q}, + {"type":"tool_use","id":"toolu_3","name":%q,"input":{}}, + {"type":"tool_use","id":"toolu_4","name":%q,"input":{}} + ]}`, bashAlias, repeatedAlias, mangledToolIDAlias, repeatedToolIDAlias, extraWordAlias)) + + restored, errReverse := reverseRemapOAuthToolNames(response, reverseMap) + if errReverse != nil { + t.Fatalf("reverseRemapOAuthToolNames() error = %v", errReverse) + } + if got := gjson.GetBytes(restored, "content.0.name").String(); got != "Bash" { + t.Fatalf("exact alias restored to %q, want Bash", got) + } + if got := gjson.GetBytes(restored, "content.1.name").String(); got != "Bash" { + t.Fatalf("repeated alias restored to %q, want Bash", got) + } + if got := gjson.GetBytes(restored, "content.2.tool_name").String(); got != "Bash" { + t.Fatalf("mangled toolID alias restored to %q, want Bash", got) + } + if got := gjson.GetBytes(restored, "content.3.name").String(); got != "Bash" { + t.Fatalf("repeated toolID alias restored to %q, want Bash", got) + } + if got := gjson.GetBytes(restored, "content.4.name").String(); got != "Bash" { + t.Fatalf("extra-word alias restored to %q, want Bash", got) + } +} + func TestReverseRemapOAuthToolNamesRejectsUnsafeMangledAliases(t *testing.T) { body := []byte(`{"tools":[{"name":"tool.name"},{"name":"tool/name"}]}`) remapped, reverseMap := remapOAuthToolNamesWithOptions(body, claudeMCPAliasOptions{secret: "ambiguous-alias-caller"}) @@ -300,6 +355,44 @@ func TestReverseRemapOAuthToolNamesRejectsUnsafeMangledAliases(t *testing.T) { } } +func TestReverseRemapOAuthToolNames_OverlappingSemanticSuffix(t *testing.T) { + body := []byte(`{"tools":[{"name":"file"},{"name":"read_file"}]}`) + remapped, reverseMap := remapOAuthToolNamesWithOptions(body, claudeMCPAliasOptions{secret: "overlapping-caller"}) + fileAlias := gjson.GetBytes(remapped, "tools.0.name").String() + readFileAlias := gjson.GetBytes(remapped, "tools.1.name").String() + + if !helps.IsClaudeMCPToolName(fileAlias) { + t.Fatalf("fileAlias is invalid: %q", fileAlias) + } + readFileParts, ok := parseClaudeMCPAlias(readFileAlias) + if !ok { + t.Fatalf("parseClaudeMCPAlias(%q) failed", readFileAlias) + } + + // Model generates repeated toolID for read_file: mcp______read_file + // Even though "_file" is a suffix of "_read_file", longest match should resolve to "read_file" + driftedReadFile := "mcp__" + readFileParts.server + "__" + readFileParts.toolID + "_" + readFileParts.toolID + "_read_file" + response := []byte(fmt.Sprintf(`{"content":[{"type":"tool_use","id":"toolu_1","name":%q,"input":{}}]}`, driftedReadFile)) + + restored, errReverse := reverseRemapOAuthToolNames(response, reverseMap) + if errReverse != nil { + t.Fatalf("reverseRemapOAuthToolNames() error = %v", errReverse) + } + if got := gjson.GetBytes(restored, "content.0.name").String(); got != "read_file" { + t.Fatalf("restored tool name = %q, want read_file", got) + } + + // Exact file alias still resolves to file + responseFile := []byte(fmt.Sprintf(`{"content":[{"type":"tool_use","id":"toolu_2","name":%q,"input":{}}]}`, fileAlias)) + restoredFile, errFile := reverseRemapOAuthToolNames(responseFile, reverseMap) + if errFile != nil { + t.Fatalf("reverseRemapOAuthToolNames(file) error = %v", errFile) + } + if got := gjson.GetBytes(restoredFile, "content.0.name").String(); got != "file" { + t.Fatalf("restored tool name = %q, want file", got) + } +} + func TestReverseRemapOAuthToolNamesPreservesUnrelatedMCPName(t *testing.T) { body := []byte(`{"tools":[{"name":"glob"}]}`) _, reverseMap := remapOAuthToolNamesWithOptions(body, claudeMCPAliasOptions{secret: "unrelated-mcp-caller"}) diff --git a/internal/runtime/executor/helps/claude_bip39_words.txt b/internal/runtime/executor/helps/claude_bip39_words.txt new file mode 100644 index 000000000..942040ed5 --- /dev/null +++ b/internal/runtime/executor/helps/claude_bip39_words.txt @@ -0,0 +1,2048 @@ +abandon +ability +able +about +above +absent +absorb +abstract +absurd +abuse +access +accident +account +accuse +achieve +acid +acoustic +acquire +across +act +action +actor +actress +actual +adapt +add +addict +address +adjust +admit +adult +advance +advice +aerobic +affair +afford +afraid +again +age +agent +agree +ahead +aim +air +airport +aisle +alarm +album +alcohol +alert +alien +all +alley +allow +almost +alone +alpha +already +also +alter +always +amateur +amazing +among +amount +amused +analyst +anchor +ancient +anger +angle +angry +animal +ankle +announce +annual +another +answer +antenna +antique +anxiety +any +apart +apology +appear +apple +approve +april +arch +arctic +area +arena +argue +arm +armed +armor +army +around +arrange +arrest +arrive +arrow +art +artefact +artist +artwork +ask +aspect +assault +asset +assist +assume +asthma +athlete +atom +attack +attend +attitude +attract +auction +audit +august +aunt +author +auto +autumn +average +avocado +avoid +awake +aware +away +awesome +awful +awkward +axis +baby +bachelor +bacon +badge +bag +balance +balcony +ball +bamboo +banana +banner +bar +barely +bargain +barrel +base +basic +basket +battle +beach +bean +beauty +because +become +beef +before +begin +behave +behind +believe +below +belt +bench +benefit +best +betray +better +between +beyond +bicycle +bid +bike +bind +biology +bird +birth +bitter +black +blade +blame +blanket +blast +bleak +bless +blind +blood +blossom +blouse +blue +blur +blush +board +boat +body +boil +bomb +bone +bonus +book +boost +border +boring +borrow +boss +bottom +bounce +box +boy +bracket +brain +brand +brass +brave +bread +breeze +brick +bridge +brief +bright +bring +brisk +broccoli +broken +bronze +broom +brother +brown +brush +bubble +buddy +budget +buffalo +build +bulb +bulk +bullet +bundle +bunker +burden +burger +burst +bus +business +busy +butter +buyer +buzz +cabbage +cabin +cable +cactus +cage +cake +call +calm +camera +camp +can +canal +cancel +candy +cannon +canoe +canvas +canyon +capable +capital +captain +car +carbon +card +cargo +carpet +carry +cart +case +cash +casino +castle +casual +cat +catalog +catch +category +cattle +caught +cause +caution +cave +ceiling +celery +cement +census +century +cereal +certain +chair +chalk +champion +change +chaos +chapter +charge +chase +chat +cheap +check +cheese +chef +cherry +chest +chicken +chief +child +chimney +choice +choose +chronic +chuckle +chunk +churn +cigar +cinnamon +circle +citizen +city +civil +claim +clap +clarify +claw +clay +clean +clerk +clever +click +client +cliff +climb +clinic +clip +clock +clog +close +cloth +cloud +clown +club +clump +cluster +clutch +coach +coast +coconut +code +coffee +coil +coin +collect +color +column +combine +come +comfort +comic +common +company +concert +conduct +confirm +congress +connect +consider +control +convince +cook +cool +copper +copy +coral +core +corn +correct +cost +cotton +couch +country +couple +course +cousin +cover +coyote +crack +cradle +craft +cram +crane +crash +crater +crawl +crazy +cream +credit +creek +crew +cricket +crime +crisp +critic +crop +cross +crouch +crowd +crucial +cruel +cruise +crumble +crunch +crush +cry +crystal +cube +culture +cup +cupboard +curious +current +curtain +curve +cushion +custom +cute +cycle +dad +damage +damp +dance +danger +daring +dash +daughter +dawn +day +deal +debate +debris +decade +december +decide +decline +decorate +decrease +deer +defense +define +defy +degree +delay +deliver +demand +demise +denial +dentist +deny +depart +depend +deposit +depth +deputy +derive +describe +desert +design +desk +despair +destroy +detail +detect +develop +device +devote +diagram +dial +diamond +diary +dice +diesel +diet +differ +digital +dignity +dilemma +dinner +dinosaur +direct +dirt +disagree +discover +disease +dish +dismiss +disorder +display +distance +divert +divide +divorce +dizzy +doctor +document +dog +doll +dolphin +domain +donate +donkey +donor +door +dose +double +dove +draft +dragon +drama +drastic +draw +dream +dress +drift +drill +drink +drip +drive +drop +drum +dry +duck +dumb +dune +during +dust +dutch +duty +dwarf +dynamic +eager +eagle +early +earn +earth +easily +east +easy +echo +ecology +economy +edge +edit +educate +effort +egg +eight +either +elbow +elder +electric +elegant +element +elephant +elevator +elite +else +embark +embody +embrace +emerge +emotion +employ +empower +empty +enable +enact +end +endless +endorse +enemy +energy +enforce +engage +engine +enhance +enjoy +enlist +enough +enrich +enroll +ensure +enter +entire +entry +envelope +episode +equal +equip +era +erase +erode +erosion +error +erupt +escape +essay +essence +estate +eternal +ethics +evidence +evil +evoke +evolve +exact +example +excess +exchange +excite +exclude +excuse +execute +exercise +exhaust +exhibit +exile +exist +exit +exotic +expand +expect +expire +explain +expose +express +extend +extra +eye +eyebrow +fabric +face +faculty +fade +faint +faith +fall +false +fame +family +famous +fan +fancy +fantasy +farm +fashion +fat +fatal +father +fatigue +fault +favorite +feature +february +federal +fee +feed +feel +female +fence +festival +fetch +fever +few +fiber +fiction +field +figure +file +film +filter +final +find +fine +finger +finish +fire +firm +first +fiscal +fish +fit +fitness +fix +flag +flame +flash +flat +flavor +flee +flight +flip +float +flock +floor +flower +fluid +flush +fly +foam +focus +fog +foil +fold +follow +food +foot +force +forest +forget +fork +fortune +forum +forward +fossil +foster +found +fox +fragile +frame +frequent +fresh +friend +fringe +frog +front +frost +frown +frozen +fruit +fuel +fun +funny +furnace +fury +future +gadget +gain +galaxy +gallery +game +gap +garage +garbage +garden +garlic +garment +gas +gasp +gate +gather +gauge +gaze +general +genius +genre +gentle +genuine +gesture +ghost +giant +gift +giggle +ginger +giraffe +girl +give +glad +glance +glare +glass +glide +glimpse +globe +gloom +glory +glove +glow +glue +goat +goddess +gold +good +goose +gorilla +gospel +gossip +govern +gown +grab +grace +grain +grant +grape +grass +gravity +great +green +grid +grief +grit +grocery +group +grow +grunt +guard +guess +guide +guilt +guitar +gun +gym +habit +hair +half +hammer +hamster +hand +happy +harbor +hard +harsh +harvest +hat +have +hawk +hazard +head +health +heart +heavy +hedgehog +height +hello +helmet +help +hen +hero +hidden +high +hill +hint +hip +hire +history +hobby +hockey +hold +hole +holiday +hollow +home +honey +hood +hope +horn +horror +horse +hospital +host +hotel +hour +hover +hub +huge +human +humble +humor +hundred +hungry +hunt +hurdle +hurry +hurt +husband +hybrid +ice +icon +idea +identify +idle +ignore +ill +illegal +illness +image +imitate +immense +immune +impact +impose +improve +impulse +inch +include +income +increase +index +indicate +indoor +industry +infant +inflict +inform +inhale +inherit +initial +inject +injury +inmate +inner +innocent +input +inquiry +insane +insect +inside +inspire +install +intact +interest +into +invest +invite +involve +iron +island +isolate +issue +item +ivory +jacket +jaguar +jar +jazz +jealous +jeans +jelly +jewel +job +join +joke +journey +joy +judge +juice +jump +jungle +junior +junk +just +kangaroo +keen +keep +ketchup +key +kick +kid +kidney +kind +kingdom +kiss +kit +kitchen +kite +kitten +kiwi +knee +knife +knock +know +lab +label +labor +ladder +lady +lake +lamp +language +laptop +large +later +latin +laugh +laundry +lava +law +lawn +lawsuit +layer +lazy +leader +leaf +learn +leave +lecture +left +leg +legal +legend +leisure +lemon +lend +length +lens +leopard +lesson +letter +level +liar +liberty +library +license +life +lift +light +like +limb +limit +link +lion +liquid +list +little +live +lizard +load +loan +lobster +local +lock +logic +lonely +long +loop +lottery +loud +lounge +love +loyal +lucky +luggage +lumber +lunar +lunch +luxury +lyrics +machine +mad +magic +magnet +maid +mail +main +major +make +mammal +man +manage +mandate +mango +mansion +manual +maple +marble +march +margin +marine +market +marriage +mask +mass +master +match +material +math +matrix +matter +maximum +maze +meadow +mean +measure +meat +mechanic +medal +media +melody +melt +member +memory +mention +menu +mercy +merge +merit +merry +mesh +message +metal +method +middle +midnight +milk +million +mimic +mind +minimum +minor +minute +miracle +mirror +misery +miss +mistake +mix +mixed +mixture +mobile +model +modify +mom +moment +monitor +monkey +monster +month +moon +moral +more +morning +mosquito +mother +motion +motor +mountain +mouse +move +movie +much +muffin +mule +multiply +muscle +museum +mushroom +music +must +mutual +myself +mystery +myth +naive +name +napkin +narrow +nasty +nation +nature +near +neck +need +negative +neglect +neither +nephew +nerve +nest +net +network +neutral +never +news +next +nice +night +noble +noise +nominee +noodle +normal +north +nose +notable +note +nothing +notice +novel +now +nuclear +number +nurse +nut +oak +obey +object +oblige +obscure +observe +obtain +obvious +occur +ocean +october +odor +off +offer +office +often +oil +okay +old +olive +olympic +omit +once +one +onion +online +only +open +opera +opinion +oppose +option +orange +orbit +orchard +order +ordinary +organ +orient +original +orphan +ostrich +other +outdoor +outer +output +outside +oval +oven +over +own +owner +oxygen +oyster +ozone +pact +paddle +page +pair +palace +palm +panda +panel +panic +panther +paper +parade +parent +park +parrot +party +pass +patch +path +patient +patrol +pattern +pause +pave +payment +peace +peanut +pear +peasant +pelican +pen +penalty +pencil +people +pepper +perfect +permit +person +pet +phone +photo +phrase +physical +piano +picnic +picture +piece +pig +pigeon +pill +pilot +pink +pioneer +pipe +pistol +pitch +pizza +place +planet +plastic +plate +play +please +pledge +pluck +plug +plunge +poem +poet +point +polar +pole +police +pond +pony +pool +popular +portion +position +possible +post +potato +pottery +poverty +powder +power +practice +praise +predict +prefer +prepare +present +pretty +prevent +price +pride +primary +print +priority +prison +private +prize +problem +process +produce +profit +program +project +promote +proof +property +prosper +protect +proud +provide +public +pudding +pull +pulp +pulse +pumpkin +punch +pupil +puppy +purchase +purity +purpose +purse +push +put +puzzle +pyramid +quality +quantum +quarter +question +quick +quit +quiz +quote +rabbit +raccoon +race +rack +radar +radio +rail +rain +raise +rally +ramp +ranch +random +range +rapid +rare +rate +rather +raven +raw +razor +ready +real +reason +rebel +rebuild +recall +receive +recipe +record +recycle +reduce +reflect +reform +refuse +region +regret +regular +reject +relax +release +relief +rely +remain +remember +remind +remove +render +renew +rent +reopen +repair +repeat +replace +report +require +rescue +resemble +resist +resource +response +result +retire +retreat +return +reunion +reveal +review +reward +rhythm +rib +ribbon +rice +rich +ride +ridge +rifle +right +rigid +ring +riot +ripple +risk +ritual +rival +river +road +roast +robot +robust +rocket +romance +roof +rookie +room +rose +rotate +rough +round +route +royal +rubber +rude +rug +rule +run +runway +rural +sad +saddle +sadness +safe +sail +salad +salmon +salon +salt +salute +same +sample +sand +satisfy +satoshi +sauce +sausage +save +say +scale +scan +scare +scatter +scene +scheme +school +science +scissors +scorpion +scout +scrap +screen +script +scrub +sea +search +season +seat +second +secret +section +security +seed +seek +segment +select +sell +seminar +senior +sense +sentence +series +service +session +settle +setup +seven +shadow +shaft +shallow +share +shed +shell +sheriff +shield +shift +shine +ship +shiver +shock +shoe +shoot +shop +short +shoulder +shove +shrimp +shrug +shuffle +shy +sibling +sick +side +siege +sight +sign +silent +silk +silly +silver +similar +simple +since +sing +siren +sister +situate +six +size +skate +sketch +ski +skill +skin +skirt +skull +slab +slam +sleep +slender +slice +slide +slight +slim +slogan +slot +slow +slush +small +smart +smile +smoke +smooth +snack +snake +snap +sniff +snow +soap +soccer +social +sock +soda +soft +solar +soldier +solid +solution +solve +someone +song +soon +sorry +sort +soul +sound +soup +source +south +space +spare +spatial +spawn +speak +special +speed +spell +spend +sphere +spice +spider +spike +spin +spirit +split +spoil +sponsor +spoon +sport +spot +spray +spread +spring +spy +square +squeeze +squirrel +stable +stadium +staff +stage +stairs +stamp +stand +start +state +stay +steak +steel +stem +step +stereo +stick +still +sting +stock +stomach +stone +stool +story +stove +strategy +street +strike +strong +struggle +student +stuff +stumble +style +subject +submit +subway +success +such +sudden +suffer +sugar +suggest +suit +summer +sun +sunny +sunset +super +supply +supreme +sure +surface +surge +surprise +surround +survey +suspect +sustain +swallow +swamp +swap +swarm +swear +sweet +swift +swim +swing +switch +sword +symbol +symptom +syrup +system +table +tackle +tag +tail +talent +talk +tank +tape +target +task +taste +tattoo +taxi +teach +team +tell +ten +tenant +tennis +tent +term +test +text +thank +that +theme +then +theory +there +they +thing +this +thought +three +thrive +throw +thumb +thunder +ticket +tide +tiger +tilt +timber +time +tiny +tip +tired +tissue +title +toast +tobacco +today +toddler +toe +together +toilet +token +tomato +tomorrow +tone +tongue +tonight +tool +tooth +top +topic +topple +torch +tornado +tortoise +toss +total +tourist +toward +tower +town +toy +track +trade +traffic +tragic +train +transfer +trap +trash +travel +tray +treat +tree +trend +trial +tribe +trick +trigger +trim +trip +trophy +trouble +truck +true +truly +trumpet +trust +truth +try +tube +tuition +tumble +tuna +tunnel +turkey +turn +turtle +twelve +twenty +twice +twin +twist +two +type +typical +ugly +umbrella +unable +unaware +uncle +uncover +under +undo +unfair +unfold +unhappy +uniform +unique +unit +universe +unknown +unlock +until +unusual +unveil +update +upgrade +uphold +upon +upper +upset +urban +urge +usage +use +used +useful +useless +usual +utility +vacant +vacuum +vague +valid +valley +valve +van +vanish +vapor +various +vast +vault +vehicle +velvet +vendor +venture +venue +verb +verify +version +very +vessel +veteran +viable +vibrant +vicious +victory +video +view +village +vintage +violin +virtual +virus +visa +visit +visual +vital +vivid +vocal +voice +void +volcano +volume +vote +voyage +wage +wagon +wait +walk +wall +walnut +want +warfare +warm +warrior +wash +wasp +waste +water +wave +way +wealth +weapon +wear +weasel +weather +web +wedding +weekend +weird +welcome +west +wet +whale +what +wheat +wheel +when +where +whip +whisper +wide +width +wife +wild +will +win +window +wine +wing +wink +winner +winter +wire +wisdom +wise +wish +witness +wolf +woman +wonder +wood +wool +word +work +world +worry +worth +wrap +wreck +wrestle +wrist +write +wrong +yard +year +yellow +you +young +youth +zebra +zero +zone +zoo diff --git a/internal/runtime/executor/helps/claude_mcp_alias.go b/internal/runtime/executor/helps/claude_mcp_alias.go index 59d673896..0c1191875 100644 --- a/internal/runtime/executor/helps/claude_mcp_alias.go +++ b/internal/runtime/executor/helps/claude_mcp_alias.go @@ -3,13 +3,10 @@ package helps import ( "crypto/hmac" "crypto/sha256" - "encoding/base32" "encoding/binary" "strings" ) -var claudeMCPBase32 = base32.NewEncoding("abcdefghijklmnopqrstuvwxyz234567").WithPadding(base32.NoPadding) - // IsClaudeMCPToolName reports whether name follows Claude Code's MCP tool // convention and contains only characters accepted by Anthropic tool names. func IsClaudeMCPToolName(name string) bool { @@ -31,18 +28,73 @@ func IsClaudeMCPToolName(name string) bool { return true } +// ClaudeMCPAliasWordCount is the BIP-39 English dictionary size used for the +// virtual server pair and the one-word tool ID. +func ClaudeMCPAliasWordCount() int { + return len(claudeMCPAliasEnglishWords) +} + // ClaudeMCPToolAlias derives a Claude Code-style MCP tool name. Aliases from // one caller share a virtual server component. The tool component combines a // stable keyed ID with a truncated semantic suffix so the model can distinguish // tools by name while the request-local symbol table restores the exact original. -// A higher attempt changes the stable ID when a collision must be avoided. +// A higher attempt linearly probes the next word when a collision must be avoided. +// Server and tool IDs use BIP-39 English words so weak models are less likely +// to drift high-entropy Base32 fragments. func ClaudeMCPToolAlias(secret, original string, attempt uint32) string { - serverDigest := claudeMCPAliasDigest(secret, "server", "", 0) - toolDigest := claudeMCPAliasDigest(secret, "tool", original, attempt) - server := claudeMCPBase32.EncodeToString(serverDigest[:])[:12] - toolID := claudeMCPBase32.EncodeToString(toolDigest[:])[:12] - semantic := claudeMCPToolSemanticSuffix(original, 32) - return "mcp__" + server + "__" + toolID + "_" + semantic + serverDigest := claudeMCPAliasDigest(secret, "server", "") + toolDigest := claudeMCPAliasDigest(secret, "tool", original) + server := claudeMCPAliasWord(serverDigest[:], 0, 0) + "_" + claudeMCPAliasWord(serverDigest[:], 2, 0) + toolID := claudeMCPAliasWord(toolDigest[:], 0, attempt) + + prefix := "mcp__" + server + "__" + toolID + "_" + maxSemanticLen := 64 - len(prefix) + if maxSemanticLen < 1 { + maxSemanticLen = 1 + } + semantic := claudeMCPToolSemanticSuffix(original, maxSemanticLen) + return prefix + semantic +} + +// AllocateClaudeMCPToolAlias picks an alias that is not already reserved. +// Attempts are capped at the wordlist size so names that sanitize to the same +// suffix cannot spin forever. ok is false only when every one-word tool ID for +// this semantic is already reserved. +func AllocateClaudeMCPToolAlias(secret, original string, reserved map[string]bool) (string, bool) { + words := claudeMCPAliasEnglishWords + totalWords := len(words) + if totalWords == 0 { + return "", false + } + serverDigest := claudeMCPAliasDigest(secret, "server", "") + toolDigest := claudeMCPAliasDigest(secret, "tool", original) + server := claudeMCPAliasWord(serverDigest[:], 0, 0) + "_" + claudeMCPAliasWord(serverDigest[:], 2, 0) + baseIndex := int(binary.BigEndian.Uint16(toolDigest[0:2])) % totalWords + + for attempt := 0; attempt < totalWords; attempt++ { + toolID := words[(baseIndex+attempt)%totalWords] + prefix := "mcp__" + server + "__" + toolID + "_" + maxSemanticLen := 64 - len(prefix) + if maxSemanticLen < 1 { + maxSemanticLen = 1 + } + semantic := claudeMCPToolSemanticSuffix(original, maxSemanticLen) + alias := prefix + semantic + if reserved != nil && reserved[alias] { + continue + } + return alias, true + } + return "", false +} + +func claudeMCPAliasWord(digest []byte, offset int, attempt uint32) string { + words := claudeMCPAliasEnglishWords + if len(words) == 0 || offset < 0 || offset+2 > len(digest) { + return "tool" + } + base := int(binary.BigEndian.Uint16(digest[offset : offset+2])) + return words[(base+int(attempt))%len(words)] } func claudeMCPToolSemanticSuffix(original string, maxLength int) string { @@ -72,15 +124,12 @@ func claudeMCPToolSemanticSuffix(original string, maxLength int) string { return result } -func claudeMCPAliasDigest(secret, purpose, original string, attempt uint32) [sha256.Size]byte { +func claudeMCPAliasDigest(secret, purpose, original string) [sha256.Size]byte { mac := hmac.New(sha256.New, []byte(secret)) _, _ = mac.Write([]byte("cpa-claude-mcp-alias-v2\x00")) _, _ = mac.Write([]byte(purpose)) _, _ = mac.Write([]byte{0}) _, _ = mac.Write([]byte(original)) - var counter [4]byte - binary.BigEndian.PutUint32(counter[:], attempt) - _, _ = mac.Write(counter[:]) var digest [sha256.Size]byte copy(digest[:], mac.Sum(nil)) return digest diff --git a/internal/runtime/executor/helps/claude_mcp_alias_test.go b/internal/runtime/executor/helps/claude_mcp_alias_test.go index ad5939976..06f23cca8 100644 --- a/internal/runtime/executor/helps/claude_mcp_alias_test.go +++ b/internal/runtime/executor/helps/claude_mcp_alias_test.go @@ -1,6 +1,7 @@ package helps import ( + "fmt" "regexp" "strings" "testing" @@ -48,9 +49,10 @@ func TestClaudeMCPToolAlias(t *testing.T) { if !strings.HasSuffix(first, "_search_web") { t.Fatalf("generated alias %q does not preserve the semantic suffix", first) } - if matched, _ := regexp.MatchString(`^mcp__[a-z2-7]{12}__[a-z2-7]{12}_search_web$`, first); !matched { - t.Fatalf("generated alias %q does not contain keyed IDs plus semantics", first) + if matched, _ := regexp.MatchString(`^mcp__[a-z]+_[a-z]+__[a-z]+_search_web$`, first); !matched { + t.Fatalf("generated alias %q does not contain word-based IDs plus semantics", first) } + assertClaudeMCPAliasWords(t, first) server := strings.Split(first, "__")[1] if got := strings.Split(caseDistinct, "__")[1]; got != server { t.Fatalf("case-distinct tool server = %q, want shared caller server %q", got, server) @@ -65,15 +67,13 @@ func TestClaudeMCPToolAlias(t *testing.T) { func TestClaudeMCPToolAlias_SemanticSuffixIsSafeAndBounded(t *testing.T) { tests := []struct { - name string - original string - wantSuffix string - wantAliasLen int + name string + original string + wantSuffix string }{ {name: "invalid separators", original: "browser.open URL", wantSuffix: "_browser_open_URL"}, {name: "unicode mixed", original: "search.网页/tool with spaces", wantSuffix: "_search_tool_with_spaces"}, {name: "unicode only", original: "搜索网页", wantSuffix: "_tool"}, - {name: "maximum length", original: strings.Repeat("a", 100), wantSuffix: "_" + strings.Repeat("a", 32), wantAliasLen: 64}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -84,12 +84,139 @@ func TestClaudeMCPToolAlias_SemanticSuffixIsSafeAndBounded(t *testing.T) { if len(alias) > 64 { t.Fatalf("generated alias length = %d, want <= 64: %q", len(alias), alias) } - if tt.wantAliasLen > 0 && len(alias) != tt.wantAliasLen { - t.Fatalf("generated alias length = %d, want %d", len(alias), tt.wantAliasLen) - } if !strings.HasSuffix(alias, tt.wantSuffix) { t.Fatalf("generated alias %q does not end in %q", alias, tt.wantSuffix) } }) } + + const original = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + alias := ClaudeMCPToolAlias("credential-secret", original, 0) + underscore := strings.LastIndex(alias, "_") + if underscore < 0 { + t.Fatalf("generated alias %q has no semantic separator", alias) + } + prefixLen := underscore + 1 + wantSemanticLen := 64 - prefixLen + if wantSemanticLen < 1 { + wantSemanticLen = 1 + } + if got := alias[prefixLen:]; got != strings.Repeat("a", wantSemanticLen) { + t.Fatalf("semantic suffix = %q, want %d a's", got, wantSemanticLen) + } + if len(alias) != 64 { + t.Fatalf("generated alias length = %d, want 64: %q", len(alias), alias) + } +} + +func TestClaudeMCPToolAlias_Strict64CharLimitUnderAllWordCombinations(t *testing.T) { + for i := 0; i < ClaudeMCPAliasWordCount(); i++ { + secret := fmt.Sprintf("test-secret-%d", i) + original := strings.Repeat(fmt.Sprintf("tool_%d_long_name_", i), 50) + alias := ClaudeMCPToolAlias(secret, original, uint32(i)) + if len(alias) > 64 { + t.Fatalf("alias length %d exceeds Anthropic 64-char limit: %q", len(alias), alias) + } + if !IsClaudeMCPToolName(alias) { + t.Fatalf("alias %q is not a valid MCP tool name", alias) + } + assertClaudeMCPAliasWords(t, alias) + } +} + +func TestAllocateClaudeMCPToolAlias_StopsWhenAttemptsExhausted(t *testing.T) { + const secret = "exhaust-space" + const original = "tool.name" + reserved := make(map[string]bool, ClaudeMCPAliasWordCount()) + for attempt := 0; attempt < ClaudeMCPAliasWordCount(); attempt++ { + reserved[ClaudeMCPToolAlias(secret, original, uint32(attempt))] = true + } + if _, ok := AllocateClaudeMCPToolAlias(secret, original, reserved); ok { + t.Fatal("allocate succeeded after every attempt alias was reserved") + } + if alias, ok := AllocateClaudeMCPToolAlias(secret, original, nil); !ok || alias == "" { + t.Fatal("allocate failed with an empty reserved set") + } +} + +func TestClaudeMCPToolAlias_ProbesAllWordsWithoutDuplicates(t *testing.T) { + const secret = "test-secret" + const original = "tool.name" + totalWords := ClaudeMCPAliasWordCount() + seen := make(map[string]bool, totalWords) + + for attempt := 0; attempt < totalWords; attempt++ { + alias := ClaudeMCPToolAlias(secret, original, uint32(attempt)) + parts := strings.Split(alias, "__") + toolID, _, _ := strings.Cut(parts[2], "_") + if seen[toolID] { + t.Fatalf("attempt %d generated duplicate toolID %q", attempt, toolID) + } + seen[toolID] = true + } + if len(seen) != totalWords { + t.Fatalf("covered %d words in %d attempts, want 100%% (%d words)", len(seen), totalWords, totalWords) + } +} + +func TestAllocateClaudeMCPToolAlias_AllocatesEveryDistinctWord(t *testing.T) { + const secret = "allocate-full-space" + const original = "tool.name" + totalWords := ClaudeMCPAliasWordCount() + reserved := make(map[string]bool, totalWords) + + for i := 0; i < totalWords; i++ { + alias, ok := AllocateClaudeMCPToolAlias(secret, original, reserved) + if !ok { + t.Fatalf("failed to allocate at step %d with %d words reserved", i, len(reserved)) + } + if reserved[alias] { + t.Fatalf("allocated duplicate alias %q at step %d", alias, i) + } + reserved[alias] = true + } + if len(reserved) != totalWords { + t.Fatalf("reserved count = %d, want %d", len(reserved), totalWords) + } + if _, ok := AllocateClaudeMCPToolAlias(secret, original, reserved); ok { + t.Fatal("allocate succeeded when all 2048 words are reserved") + } +} + +func BenchmarkAllocateClaudeMCPToolAlias_Collision(b *testing.B) { + const secret = "test-secret" + const original = "tool.name" + reserved := make(map[string]bool) + for attempt := 0; attempt < 100; attempt++ { + reserved[ClaudeMCPToolAlias(secret, original, uint32(attempt))] = true + } + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, _ = AllocateClaudeMCPToolAlias(secret, original, reserved) + } +} + +func assertClaudeMCPAliasWords(t *testing.T, alias string) { + t.Helper() + parts := strings.Split(alias, "__") + if len(parts) != 3 { + t.Fatalf("alias %q does not have mcp/server/tool parts", alias) + } + serverWords := strings.Split(parts[1], "_") + if len(serverWords) != 2 { + t.Fatalf("alias %q server %q is not two BIP-39 words", alias, parts[1]) + } + toolID, _, ok := strings.Cut(parts[2], "_") + if !ok { + t.Fatalf("alias %q tool component %q has no semantic suffix", alias, parts[2]) + } + allowed := make(map[string]struct{}, len(claudeMCPAliasEnglishWords)) + for _, word := range claudeMCPAliasEnglishWords { + allowed[word] = struct{}{} + } + for _, word := range append(append([]string{}, serverWords...), toolID) { + if _, exists := allowed[word]; !exists { + t.Fatalf("alias %q uses non-BIP39 word %q", alias, word) + } + } } diff --git a/internal/runtime/executor/helps/claude_mcp_alias_wordlist.go b/internal/runtime/executor/helps/claude_mcp_alias_wordlist.go new file mode 100644 index 000000000..2afb009ab --- /dev/null +++ b/internal/runtime/executor/helps/claude_mcp_alias_wordlist.go @@ -0,0 +1,12 @@ +package helps + +import ( + _ "embed" + "strings" +) + +//go:embed claude_bip39_words.txt +var rawBIP39EnglishWords string + +// claudeMCPAliasEnglishWords contains the standard BIP-39 English wordlist (2048 words). +var claudeMCPAliasEnglishWords = strings.Fields(rawBIP39EnglishWords)