Skip to content

Avoid priority annexing unlisted tools - #6127

Open
kocaemre wants to merge 2 commits into
stacklok:mainfrom
kocaemre:fix/priority-tool-unlisted-conflict
Open

kocaemre wants to merge 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 today, that could redirect an existing Tool::"name" permit to a different backend's tool when a listed backend later introduced the same name.
  • Treat any tool-name collision involving an unlisted backend as not safely rank-comparable: prefix every candidate instead of advertising a bare winner.
  • Add a regression case covering a mixed listed/unlisted deploy conflict so both tools remain reachable as github_deploy and prod_deploy.

Fixes #6097

Type of change

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

Test plan

  • Unit tests (task test)
  • E2E tests (task test-e2e)
  • Linting (task lint-fix)
  • Manual testing (describe below)

Manual testing:

  • RED before fix: PATH=/usr/local/go/bin:/root/go/bin:$PATH go test ./pkg/vmcp/aggregator -run 'TestPriorityConflictResolver/mixed_listed_and_unlisted_conflict_uses_prefix_fallback' failed with only one resolved tool and the unlisted backend dropped.
  • GREEN after fix: PATH=/usr/local/go/bin:/root/go/bin:$PATH go test ./pkg/vmcp/aggregator -run TestPriorityConflictResolver
  • PATH=/usr/local/go/bin:/root/go/bin:$PATH go test ./pkg/vmcp/aggregator
  • PATH=/usr/local/go/bin:/root/go/bin:$PATH go test -race ./pkg/vmcp/aggregator -run TestPriorityConflictResolver
  • PATH=/usr/local/go/bin:/root/go/bin:$PATH task lint
  • git diff --check

API Compatibility

  • This PR does not break the v1beta1 API, OR the api-break-allowed label is applied and the migration guidance is described above.

Does this introduce a user-facing change?

Yes. Under the priority conflict strategy, a tool-name collision that includes any backend absent from priorityOrder now advertises the conflicting candidates with workload prefixes instead of selecting a bare-name winner.

Special notes for reviewers

Conflicts where all candidates are listed in priorityOrder keep the existing priority-winner behavior. The prefix fallback is only for conflicts that include at least one unlisted backend, where no complete rank comparison exists. That fallback prefixes every candidate in the collision, so a listed backend's previously bare tool name can also be renamed when it collides with an unlisted backend.

Reviewer follow-up addressed:

  • Added 3-way mixed listed/unlisted regression coverage.
  • Updated stale priority resolver comments.
  • Raised the prefix-fallback log from Debug to Warn.

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

"prod_deploy": vmcp.ConflictStrategyPrefix,
},
},
{

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.

[MEDIUM] Missing test coverage for 3+-way conflicts mixing listed and unlisted backends (Consensus: 8/10)

This case covers exactly 1 listed + 1 unlisted backend. No case proves that a conflict with 2+ listed backends plus 1 unlisted backend still prefixes all candidates, rather than letting the listed backends fall back to rank-comparison among themselves — the scenario this PR's own reviewer notes call out. hasUnlistedCandidate/addPrefixedCandidates already handle this correctly by inspection, but nothing pins it down.

Consider adding a sibling case, e.g.:

{
	name:          "three-way conflict with unlisted backend forces prefix for all",
	priorityOrder: []string{"a", "b"},
	toolsByBackend: map[string][]vmcp.Tool{
		"a":        {{Name: "deploy"}},
		"b":        {{Name: "deploy"}},
		"unlisted": {{Name: "deploy"}},
	},
	wantCount: 3,
	wantWinners: map[string]string{
		"a_deploy":        "a",
		"b_deploy":        "b",
		"unlisted_deploy": "unlisted",
	},
	wantStrategies: map[string]vmcp.ConflictResolutionStrategy{
		"a_deploy":        vmcp.ConflictStrategyPrefix,
		"b_deploy":        vmcp.ConflictStrategyPrefix,
		"unlisted_deploy": vmcp.ConflictStrategyPrefix,
	},
},

Raised by: test-coverage

Comment on lines 163 to 164
// Returns nil if none of the candidates are in the priority list.
func (r *PriorityConflictResolver) selectWinner(candidates []toolWithBackend) *toolWithBackend {

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.

[MEDIUM] selectWinner's doc comment is stale relative to its only call site (Consensus: 8/10)

selectWinner is now only invoked after hasUnlistedCandidate confirms every candidate is listed, so it can never return nil at this call site — yet the doc comment doesn't say so, and the caller dereferences winner.Tool... without a nil check. Not a live bug today, but a latent nil-pointer-dereference hazard for any future caller that skips the hasUnlistedCandidate guard.

Suggested change
// Returns nil if none of the candidates are in the priority list.
func (r *PriorityConflictResolver) selectWinner(candidates []toolWithBackend) *toolWithBackend {
// selectWinner chooses the tool from the highest-priority backend.
// Returns nil if none of the candidates are in the priority list. Callers must
// ensure at least one candidate is present in priorityMap (see hasUnlistedCandidate)
// before dereferencing the result unconditionally.

Raised by: correctness-security, code-quality

}
slog.Debug("tool exists in backends not in priority order, using prefix fallback",
slog.Debug("tool conflict includes backend not in priority order, using prefix fallback",
"tool", toolName, "backends", backendIDs)

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.

[MEDIUM] Fallback rename of a listed backend's tool only logged at Debug (Consensus: 7/10)

Per the project's logging convention, WARN is for fallback behavior. This path now fires whenever any candidate is unlisted (broadened from "all unlisted"), and can rename a previously bare-named, Cedar-policy-bound tool belonging to a listed backend — with no signal above Debug that an operator's existing policy just stopped matching.

Suggested change
"tool", toolName, "backends", backendIDs)
slog.Warn("tool conflict includes backend not in priority order, using prefix fallback",

Raised by: correctness-security

@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.

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