Skip to content

Avoid priority annexing unlisted tools - #6127

Merged
JAORMX merged 2 commits into
stacklok:mainfrom
kocaemre:fix/priority-tool-unlisted-conflict
Sep 25, 2026
Merged

JAORMX merged 2 commits into
stacklok:mainfrom
kocaemre:fix/priority-tool-unlisted-conflict

Conversation

@kocaemre

@kocaemre kocaemre commented Jul 28, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • The priority tool resolver could silently award a bare tool name to a listed backend when that name also came from a backend absent from priorityOrder. Because Cedar tool authorization is name-only, this could redirect an existing permit to a different backend's tool.
  • Drop every candidate in a tool-name collision if any participating backend is absent from priorityOrder, and log the dropped conflict at error level. Do not generate prefix aliases: renaming could bypass existing name-scoped forbids or collide with another tool's real name.
  • Preserve conflict-free tools, including those from unlisted backends. When all conflicting backends are listed, retain the existing priority-winner behavior.
  • Cover all-unlisted, mixed listed/unlisted, and three-way mixed collisions in resolver tests.

Fixes #6097

Type of change

  • Bug fix
  • New feature
  • Refactoring (no behavior change)
  • Dependency update
  • Documentation
  • Other (describe):

Test plan

  • Unit tests (task test) — GitHub CI on 834d8346
  • E2E tests (task test-e2e) — GitHub CI core/operator matrices and MCP conformance on 834d8346
  • Linting (task lint-fix)
  • Manual testing (describe below)

The author reported focused aggregator tests, race tests, and git diff --check passing after the rework/rebase; see the verification comments for exact commands and local Taskfile/toolchain limitations. All 42 GitHub checks passed on 834d8346, including Go tests and lint. Re-review traced the resolver results through aggregation and routing; no additional local test run was performed for that review.

Does this introduce a user-facing change?

Yes. With the priority strategy, a tool-name collision involving any backend absent from priorityOrder now removes every candidate from advertising and routing, with an error-level diagnostic. This affects both listed and unlisted participants. There is no prefix fallback. Conflict-free tools and fully listed priority conflicts retain their existing behavior.

Special notes for reviewers

The original prefix-based approach was removed in response to review to preserve name-scoped authorization. Removing generated aliases also removes the reported cross-name overwrite path; no new collision-resolution machinery is needed for that path.

The resolver tests assert absence of bare and generated-prefix names; they do not evaluate Cedar policies. Follow-up cleanup will replace the duplicated mixed-conflict test with a distinct real alias-shaped-name regression and clarify the priority documentation.

ChrisJBurns
ChrisJBurns previously approved these changes Jul 30, 2026

@ChrisJBurns ChrisJBurns left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Multi-Agent Consensus Review

Agents consulted: correctness-security, test-coverage, code-quality (pr-reviewer specialists)

Consensus Summary

# Finding Consensus Severity Action
1 Missing test coverage for 3+-way conflicts mixing listed and unlisted backends 8/10 MEDIUM Suggest
2 selectWinner's nil-return doc comment is stale relative to its only call site 8/10 MEDIUM Suggest
3 Fallback rename of a listed backend's tool is only logged at Debug 7/10 MEDIUM Discuss

Overall

This PR closes #6097 correctly: it inverts the conflict-resolution control flow so that any tool-name collision involving a backend absent from priorityOrder is treated as not safely rank-comparable, prefixing every candidate instead of letting a listed backend silently annex the bare name. Tracing the fix against the issue's exact repro steps confirms the annexation hole is closed, and the "forbid fails closed" invariant is preserved since the fallback renames but never drops candidates.

The remaining findings are refinements, not correctness gaps: the fix already generalizes to N-way conflicts by checking "any candidate unlisted" rather than "exactly one," but no test proves it; a stale doc comment on selectWinner no longer matches the guarantees of its only call site; and the fallback path — now reached more often since it broadened from "all unlisted" to "any unlisted" — renames a listed backend's previously-bare, Cedar-policy-bound tool name while only logging at Debug.

Documentation

No documentation files are affected by this diff; consider a one-line note in the PR description that the rename can also affect a listed backend's tool when it collides with an unlisted one, not just the unlisted backend's tool.


Generated with Claude Code

Comment thread pkg/vmcp/aggregator/conflict_resolver_test.go
Comment thread pkg/vmcp/aggregator/priority_resolver.go Outdated
Comment thread pkg/vmcp/aggregator/priority_resolver.go
@ChrisJBurns

Copy link
Copy Markdown
Collaborator

@kocaemre you able to address the above?

@kocaemre
kocaemre force-pushed the fix/priority-tool-unlisted-conflict branch from b9b3789 to 0fa55cc Compare August 3, 2026 15:18
@kocaemre

kocaemre commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

Addressed, thanks. I added the 3-way mixed listed/unlisted regression, updated the stale resolver comments, and changed the prefix-fallback log from Debug to Warn. Also updated the PR description with the reviewer follow-up and latest verification commands.

amirejaz
amirejaz previously approved these changes Aug 7, 2026
@github-actions github-actions Bot added size/S Small PR: 100-299 lines changed and removed size/S Small PR: 100-299 lines changed labels Aug 7, 2026
@codecov

codecov Bot commented Aug 7, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 78.91%. Comparing base (e532cf0) to head (834d834).

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #6127      +/-   ##
==========================================
- Coverage   78.98%   78.91%   -0.08%     
==========================================
  Files         782      782              
  Lines       78065    78058       -7     
==========================================
- Hits        61658    61597      -61     
- Misses      16402    16456      +54     
  Partials        5        5              

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@kocaemre

Copy link
Copy Markdown
Contributor Author

One remaining legacy E2E Test Lifecycle (kindest/node:v1.34.3) check is red. I inspected the failed job log: it fails in test/e2e/thv-operator/virtualmcp/virtualmcp_redis_session_test.go around Redis-backed session sharing / pod readiness (ErrImagePull for the intentionally nonexistent image and connection-refused readiness/proxy errors). The PR itself only touches pkg/vmcp/aggregator/priority_resolver.go and its resolver tests, and the newer PR CI suite passed the relevant Go tests, lint, operator jobs, MCP conformance, Codecov, and E2E core/operator matrices.

I attempted to rerun the failed check from the CLI; if GitHub permissions allow it, this comment can be ignored. Otherwise, could a maintainer rerun that failed legacy matrix job?

@kocaemre
kocaemre force-pushed the fix/priority-tool-unlisted-conflict branch from 0fa55cc to 47e17ca Compare September 9, 2026 07:04
@kocaemre

kocaemre commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

Refreshed this PR branch onto current main (ab27a23c) and force-pushed rebased head 47e17ca6.

No code changes beyond replaying the existing PR commit on top of current upstream.

Local verification in this cron environment:

PATH=/usr/local/go/bin:/root/go/bin:$PATH go test -ldflags=-extldflags=-Wl,-w -race ./pkg/vmcp/aggregator -run TestPriorityConflictResolver
# ok github.com/stacklok/toolhive/pkg/vmcp/aggregator 1.215s

PATH=/usr/local/go/bin:/root/go/bin:$PATH golangci-lint run --allow-parallel-runners ./pkg/vmcp/aggregator/...
# 0 issues.

PATH=/usr/local/go/bin:/root/go/bin:$PATH go vet ./pkg/vmcp/aggregator/...
# passed

git diff --check origin/main..HEAD
# passed

git log --format='%h %s%n%b' origin/main..HEAD
# 47e17ca6 Avoid priority annexing unlisted tools
# Signed-off-by: Emre K <110906681+kocaemre@users.noreply.github.com>

I also attempted the full repo task lint, but it exceeded this cron job's 10-minute command timeout while running the repo-wide golangci-lint run --allow-parallel-runners ./...; the focused aggregator lint/vet checks above completed successfully.

GitHub had not populated the refreshed check rollup yet immediately after the push (statusCheckRollup was empty), so I'll rely on the new CI run once it starts.

@kocaemre

kocaemre commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

Follow-up on the refreshed CI run: the only red check I see is E2E Tests / E2E Tests Core (core) from run 34322045823.

Failure evidence from the job log:

[FAIL] THVIgnore E2E Tests Performance and scalability when processing large numbers of files
should handle directories with many files efficiently
failed to start transport: failed to listen: listen tcp 127.0.0.1:31903: bind: address already in use

This looks like an E2E runner port collision in test/e2e/thvignore_test.go, not related to this PR's pkg/vmcp/aggregator/priority_resolver.go change. The focused local checks for the touched package passed after the rebase (see the previous comment), and all other CI checks in the new run passed.

I attempted to rerun the failed job from the CLI, but GitHub refused it for my account on this repo:

gh api -X POST repos/stacklok/toolhive/actions/runs/34322045823/rerun-failed-jobs
# HTTP 403: Must have admin rights to Repository.

Could a maintainer rerun that failed E2E core job when convenient?

@kocaemre
kocaemre force-pushed the fix/priority-tool-unlisted-conflict branch from 47e17ca to 0944c34 Compare September 10, 2026 00:46
@kocaemre

Copy link
Copy Markdown
Contributor Author

Refreshed this PR branch onto current main (cdb04c94f) and force-pushed rebased head 0944c34c0.

No code changes beyond replaying the existing priority-conflict fix on top of current upstream. The previous failed GitHub check looked unrelated to this PR: E2E Tests Core (core) failed in THVIgnore E2E Tests ... should handle directories with many files efficiently because the test workload hit listen tcp 127.0.0.1:31903: bind: address already in use; the other 42 checks in that run were green.

Local verification after the refresh:

git diff --check origin/main..HEAD
# passed

PATH=/usr/local/go/bin:/root/go/bin:$PATH go test -run 'TestPriorityConflictResolver' ./pkg/vmcp/aggregator
# ok  github.com/stacklok/toolhive/pkg/vmcp/aggregator  0.086s

PATH=/usr/local/go/bin:/root/go/bin:$PATH go test ./pkg/vmcp/aggregator
# ok  github.com/stacklok/toolhive/pkg/vmcp/aggregator  0.091s

PATH=/usr/local/go/bin:/root/go/bin:$PATH golangci-lint run --allow-parallel-runners ./pkg/vmcp/aggregator
# 0 issues.

I attempted the repo-level task lint, but this cron environment timed out after 10 minutes, so I kept the validation scoped to the touched package and the refreshed GitHub CI run.

@kocaemre

Copy link
Copy Markdown
Contributor Author

Investigated the new red Tests / Test Go Code (ubuntu-8cores-32gb) check from run 34422700480.

Failure evidence from the job log:

TestRoundTripReinitializesPreservesNonUUIDBackendSessionID
backend_routing_test.go:602: expected 200, actual 502
failed to forward request error="dial tcp 127.0.0.1:40567: connect: connection refused"

That failing test is in pkg/transport/proxy/transparent/backend_routing_test.go, while this PR only changes pkg/vmcp/aggregator/priority_resolver.go and its tests.

Local checks from the refreshed branch:

git diff --check origin/main..HEAD
# passed

/usr/local/go/bin/go test -race ./pkg/vmcp/aggregator -run TestPriorityConflictResolver
# ok  github.com/stacklok/toolhive/pkg/vmcp/aggregator  1.193s

/usr/local/go/bin/go test -ldflags=-extldflags=-Wl,-w -race ./pkg/transport/proxy/transparent -run TestRoundTripReinitializesPreservesNonUUIDBackendSessionID -count=1 -v
# PASS
# ok  github.com/stacklok/toolhive/pkg/transport/proxy/transparent  1.114s

I also tried to rerun the failed job, but GitHub rejected it for my account:

gh api -X POST repos/stacklok/toolhive/actions/runs/34422700480/rerun-failed-jobs
# HTTP 403: Must have admin rights to Repository.

This looks like another transient/unrelated test-runner failure rather than a regression from this PR. Could a maintainer rerun the failed Tests / Test Go Code job when convenient?

@JAORMX JAORMX left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Panel review of 0944c34c0f441363d5368a5e157e58bae89f368b against main cdb04c94f2462553fc27572bc8db9bb88a0c3c78 and #6097.

Blocking — prefix fallback is an authorization fail-open

The mixed listed/unlisted path prefixes every candidate (pkg/vmcp/aggregator/priority_resolver.go:86-98). That re-advertises a tool under a name outside existing policies, so a name-scoped forbid(... Tool::"deploy") that previously denied the tool no longer applies to github_deploy / prod_deploy. This is the exact reason the architecture requires priority losers to be dropped rather than aliased (docs/arch/10-virtual-mcp-architecture.md:177-180). With name-only Cedar authorization, retaining availability cannot override the fail-closed policy invariant. Drop all candidates in an unrankable mixed collision and log it at error level; add coverage combining a broad permit with a bare-name forbid.

Blocking — generated prefix names can collide and redirect a permitted call

addPrefixedCandidates writes directly into resolved (pkg/vmcp/aggregator/priority_resolver.go:143-160) without globally checking advertised-name collisions. For example, a listed github/deploy, unlisted prod/deploy, and an evil/github_deploy tool can all claim github_deploy; random map iteration determines the surviving route. A Cedar permit for Tool::"github_deploy" can therefore execute another backend's tool. Resolve/validate all proposed names before inserting routes, and drop every claimant to an ambiguous output name (the established prompts behavior at docs/arch/10-virtual-mcp-architecture.md:182-193); test the three-backend routing and authorization case.

The fallback also changes published tool names but does not update the operator-facing priority documentation or give an actionable warning; removing the unsafe fallback resolves that compatibility/documentation concern. Standards, reuse, and duplication checks found no other merge blockers.

@kocaemre

Copy link
Copy Markdown
Contributor Author

Addressed the two blocking review findings in 4389cc326.

What changed:

  • removed the priority resolver's prefix fallback for conflicts involving any backend outside priorityOrder
  • now drops every candidate in an unrankable collision and logs it at error level, preserving the name-scoped Cedar fail-closed invariant instead of re-advertising losers under uncovered prefixed names
  • updated priority resolver coverage for unlisted-only, mixed listed/unlisted, 3-way mixed, and prefixed-name forbid-bypass cases; the tests assert the bare and prefixed names are not advertised

Local verification:

export PATH=/usr/local/go/bin:/root/go/bin:$PATH

git diff --check origin/main..HEAD
# passed

go test -run 'TestPriorityConflictResolver' ./pkg/vmcp/aggregator
# ok  github.com/stacklok/toolhive/pkg/vmcp/aggregator  0.060s

go test -race ./pkg/vmcp/aggregator -run TestPriorityConflictResolver -count=1
# ok  github.com/stacklok/toolhive/pkg/vmcp/aggregator  1.186s

go test -race ./pkg/vmcp/aggregator -count=1
# ok  github.com/stacklok/toolhive/pkg/vmcp/aggregator  1.250s

golangci-lint run --allow-parallel-runners ./pkg/vmcp/aggregator
# 0 issues.

Repo-level checks attempted:

task lint
# failed in pre-existing/out-of-scope gci formatting issues:
# ../../work/toolhive/pkg/authserver/server/provider.go:391:1: File is not properly formatted (gci)
# pkg/authserver/server_impl.go:194:1: File is not properly formatted (gci)

task test
# failed before package results due gotestfmt panic:
# panic: BUG: Empty package name encountered.

Those repo-level failures are outside pkg/vmcp/aggregator; the focused resolver tests/lint above cover this rework.

@kocaemre

Copy link
Copy Markdown
Contributor Author

CI/status follow-up for the review-fix head 4389cc326ad84ee4c80289ec7b198722c943d73a:

  • GitHub check rollup is now fully green: 41/41 checks completed successfully, 0 pending, 0 failing.
  • Key checks included Tests / Test Go Code (ubuntu-8cores-32gb), Linting / Lint Go Code, E2E Tests / E2E Tests Core (vmcp), E2E Tests / MCP Conformance (vmcp), operator tests, codegen, docs, license headers, grype, zizmor, and Codecov.
  • Current PR metadata from gh pr view: mergeable=MERGEABLE, mergeStateStatus=BLOCKED, reviewDecision=CHANGES_REQUESTED pending reviewer re-review/dismissal.

No new code changes in this follow-up; this is just the evidence snapshot after CI completed on the pushed blocker fix.

Signed-off-by: Emre K <110906681+kocaemre@users.noreply.github.com>
Signed-off-by: Emre K <110906681+kocaemre@users.noreply.github.com>
@kocaemre
kocaemre force-pushed the fix/priority-tool-unlisted-conflict branch from 4389cc3 to 834d834 Compare September 13, 2026 01:28
@kocaemre

Copy link
Copy Markdown
Contributor Author

Refreshed this PR branch onto current main (e532cf07) and force-pushed rebased head 834d8346.

No code changes beyond replaying the existing two PR commits on top of current upstream; DCO sign-offs are preserved.

Local verification after the refresh:

git diff --check upstream/main..HEAD
# passed

git log --format='%h %s%n%b' upstream/main..HEAD
# 834d8346 Drop unrankable priority conflicts
# Signed-off-by: Emre K <110906681+kocaemre@users.noreply.github.com>
# ee4954a2 Avoid priority annexing unlisted tools
# Signed-off-by: Emre K <110906681+kocaemre@users.noreply.github.com>

PATH=/usr/local/go/bin:/root/go/bin:$PATH go test -run 'TestPriorityConflictResolver' ./pkg/vmcp/aggregator
# ok  github.com/stacklok/toolhive/pkg/vmcp/aggregator  0.055s

PATH=/usr/local/go/bin:/root/go/bin:$PATH go test -race ./pkg/vmcp/aggregator -run TestPriorityConflictResolver -count=1
# ok  github.com/stacklok/toolhive/pkg/vmcp/aggregator  1.272s

I also attempted the repo-level Taskfile lint after the rebase, but this cron runner's installed golangci-lint binary is built with Go 1.26 while the refreshed repo now targets Go 1.27, so lint is blocked before analyzing packages:

PATH=/usr/local/go/bin:/root/go/bin:$PATH task lint
# Error: can't load config: the Go language version (go1.26) used to build golangci-lint is lower than the targeted Go version (1.27.0)
# task: Failed to run task "lint": exit status 3

GitHub CI has been retriggered on the refreshed head and was queued at the time of this comment.

@kocaemre

Copy link
Copy Markdown
Contributor Author

Current refreshed head 834d8346 has now finished green on GitHub CI after the rebase.

Status snapshot from this run:

  • gh pr checks 6127 --repo stacklok/toolhive: 42 pass, 0 pending, 0 failing
  • git diff --check upstream/main..HEAD: passed on a fresh worktree at 834d8346
  • DCO/sign-offs preserved on both PR commits:
    • 834d8346 Drop unrankable priority conflicts
    • ee4954a2 Avoid priority annexing unlisted tools

I also tried to re-run task test locally on the refreshed branch, but this runner's Go toolchain is too old for the current upstream go.mod:

go: errors parsing go.mod:
/tmp/toolhive-6127/go.mod:3: invalid go version '1.27.0': must match format 1.23

So the remaining blocker is only the stale CHANGES_REQUESTED review state awaiting re-review/dismissal; GitHub CI is green on the current head.

@JAORMX

JAORMX commented Sep 25, 2026

Copy link
Copy Markdown
Collaborator

Hey @kocaemre, sorry for taking so long to get back to this! You followed up on my review and I left you waiting. Thanks for working through the changes and keeping the branch up to date. I'm taking another look now.

@JAORMX JAORMX left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for working through this! Re-reviewed at 834d8346. Both blockers from my previous review are addressed: unrankable collisions now drop every candidate, and removing the prefix fallback also removes the generated-name overwrite path. The dropped tools stay out of both advertising and routing.

All 42 current checks are green. I reviewed the code and routing flow; I didn't rerun tests locally.

The remaining items are nonblocking: the PR description still describes prefix fallback, and the forbid-bypass test repeats the mixed-conflict case rather than adding coverage. We'll correct the description and follow up with a distinct alias-shaped-name regression and clearer priority documentation.

@JAORMX
JAORMX merged commit dfb0713 into stacklok:main Sep 25, 2026
44 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/S Small PR: 100-299 lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Priority tool resolver can annex an unlisted backend's tool name

4 participants