Skip to content

Scope the analyze health and clones kinds to the caller's workspace - #395

Merged
zzet merged 2 commits into
mainfrom
fix/analyze-workspace-scoping
Jul 28, 2026
Merged

Scope the analyze health and clones kinds to the caller's workspace#395
zzet merged 2 commits into
mainfrom
fix/analyze-workspace-scoping

Conversation

@zzet

@zzet zzet commented Jul 28, 2026

Copy link
Copy Markdown
Owner

Fixes #349.

What the report got right

Both reproductions are real, and the root cause is the one the reporter isolated: analyze kind=health accepts a target.repo selector and silently ignores it, while kind=clones honours it.

One correction to the framing. The title says "MCP analyze ignores workspace scoping", but that is not true of analyze generally — it is true of exactly the two kinds tested. Kinds dispatched by the analyze handler (health_score, dead_code, hotspots, …) source their rows through the scoped-node accessors and are already workspace-clamped, with a cross-workspace-leak sweep over the whole scope-aware kind set guarding it. health and clones are different: they are not analyze kinds at all.

Root cause

The analyze facade resolves kind to an operation, and the legacy-migration table maps a handful of operation names to a different legacy tool:

addFacadeGroup(&specs, "analyze", facadeEffectRead, map[string]string{
    "clones": "find_clones",  ...
    "health": "audit_health", ...
})

That table is consulted before the fall-through that synthesises a spec for a real analyze kind, so health and clones shadow the dispatcher and are forwarded straight to audit_health / find_clones. Neither ever reaches the dispatcher's resolveScope, so neither gets the workspace clamp or the repo allow-set.

From there the two diverge, which is exactly why the reporter saw the selector work for one and not the other. The facade does lower target.repo to a plain repo argument for both:

  • find_clones reads repo and filters on it — hence repro 3 — but as a bare RepoPrefix equality, with no workspace ceiling at all. So an unnarrowed call from a bound session returns sibling workspaces' clusters, and the path form of the selector matches no prefix and narrows to nothing.
  • handleAuditHealth was declared (_ context.Context, _ mcp.CallToolRequest) — it discarded the entire request — and ComputeAuditReport walked graph.AllNodes(). It could not see the selector or the session, which is why both selector forms returned the identical global report.

The invalid-key rejection the reporter noticed is the facade's own selector validation, which runs before the handler; that is why the key was policed but its value was dropped.

The fix

Two commits, one per handler.

audit_health resolves the uniform repo/project/workspace/scope narrowing the way the dispatcher does and grades symbols sourced from scopedNodesByKinds, which applies both the workspace ceiling and the resolved allow-set. ComputeAuditReport now takes the node set to grade rather than reading the whole store, the selectors are declared on the tool instead of being tolerated undocumented, and the response carries scope_applied. Fan-in/fan-out are still counted over the whole graph, so a cross-repo caller keeps contributing to a symbol's score — only which symbols get graded is scoped, matching health_score.

find_clones resolves the same narrowing and gates members on analyzeNodeVisible, replacing the bare prefix equality. It gains the tracked-name-or-path selector handling and the out-of-workspace error the rest of the analyze surface has.

One CLI change rides along: gortex audit --path sent {}, so the flag only picked the daemon connection and the badge was graded against every tracked repo — its own help text promises "audit a different tracked tree". It now forwards the path as the selector.

On single-repo daemons

Worth calling out since this backs the README badge. A single-repo daemon mints nodes with an empty RepoPrefix and has no multi-indexer to resolve a path against, so the new repo argument matches no registry prefix. QueryOptions.ScopeAllows deliberately admits unprefixed nodes rather than letting a repo narrow match vacuously, so the report is unchanged there. Pinned by a test.

Tests

Seven new tests across two files, each verified to fail against the pre-fix handlers:

  • workspace isolation for both kinds (an alpha-bound session must not see beta rows)
  • repro 2 directly: an explicit repo selector must narrow the graded set, and scope_applied must disclose it
  • both selector forms — tracked name and absolute path — narrow identically
  • an out-of-workspace selector errors instead of returning a plausible global report
  • single-repo and unbound-session behaviour unchanged

go test -race ./... passes: 11643 tests across 179 packages. golangci-lint clean.

Follow-up, deliberately not in scope here

health and clones are the two aliases with concrete reproductions, and they are fixed. The same shadowing applies to other entries in that table, and a few of those handlers are also globally unscoped — run_inspections, get_communities, get_processes, get_recent_changes, with audit_agent_config root-pinned by design. Each needs its own scoping decision (community detection runs one partition over the whole index and already has a dedicated clamp helper; agent_config is arguably correct as-is), so folding them in would mean several unrelated design calls in one change. Worth a follow-up issue.

zzet added 2 commits July 28, 2026 13:31
handleAuditHealth discarded both its context and its request, and
ComputeAuditReport walked graph.AllNodes(), so the report always graded
every symbol in the index. Reached as `analyze kind=health`, that made a
workspace-bound session report on sibling workspaces, and an explicit
repo/project/scope selector was accepted and silently ignored — the
selector is lowered to a plain `repo` argument the handler never read.

The kind is one of the analyze operations the facade routes to a legacy
tool rather than the analyze dispatcher, so it never reached the
dispatcher's resolveScope. Resolve the same uniform narrowing here and
source the graded symbols through scopedNodesByKinds, which applies the
session-workspace ceiling and the resolved allow-set. Fan-in / fan-out
are still counted over the whole graph, so a cross-repo caller keeps
contributing to a symbol's score; only which symbols get graded is
scoped. That matches the health_score analyzer.

ComputeAuditReport now takes the node set to grade instead of reading
the whole store, and the response carries scope_applied like every other
analyze result. `gortex audit --path` forwards the path as the selector,
so the flag narrows the badge instead of only picking the daemon; a
single-repo daemon is unaffected because unprefixed nodes are admitted
rather than narrowed away.
find_clones matched its `repo` argument as a bare RepoPrefix equality
and applied no workspace ceiling at all. Reached as `analyze
kind=clones`, an unnarrowed call from a workspace-bound session returned
clone clusters from sibling workspaces, and the path form of the
selector — the form the facade lowers a filesystem target into — matched
no prefix and silently narrowed to nothing.

Resolve the uniform repo/project/workspace/scope narrowing the way the
analyze dispatcher does and gate members on analyzeNodeVisible, which
enforces both the workspace ceiling and the resolved allow-set. This
also gives the tool the tracked-name-or-path selector handling and the
out-of-workspace error that the rest of the analyze surface has.
@zzet
zzet merged commit 1d1a821 into main Jul 28, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug: MCP analyze ignores workspace scoping; health kind also ignores an explicit repo target

1 participant