Skip to content

fix(mcp): close the remaining doors to a silent wrong-repository target - #585

Merged
zzet merged 3 commits into
mainfrom
fix/facade-target-selector-coverage
Aug 15, 2026
Merged

fix(mcp): close the remaining doors to a silent wrong-repository target#585
zzet merged 3 commits into
mainfrom
fix/facade-target-selector-coverage

Conversation

@zzet

@zzet zzet commented Aug 15, 2026

Copy link
Copy Markdown
Owner

Summary

Follow-up to #583. That PR fixed the cost and the wording of the repository-selector guard; this one closes the two ways a caller's stated target could still be silently discarded.

1. The guard matched four names, so the same bug had other doors

#553 refused repo, repo_path, repository and repository_path when the selected operation could not consume them. A caller who wrote options.workspace, options.root, options.cwd, options.repo_root or options.worktree still had it silently dropped and the operation ran against the active repository. On edit.file that is a write to the wrong repository reported as success — the same failure the four-name guard was added to close, reachable by writing the intent a different way.

Inventing a name does not make the intent less clear, so it must not make the failure quieter. The class now covers the spellings a caller may reasonably reach for: repo, repo_path, repository, repository_path, repo_root, repository_root, repoPath, repo-path, repo_dir, root, cwd, dir, worktree, work_tree, base_repo, workspace, project.

Membership is by name, but refusal is still decided per operation by the consumption probe, so an operation that genuinely reads workspace or root keeps receiving it. path and scope stay out and a test pins that: on this surface they name a file or a working-tree scope far more often than a repository, and promoting them refuses working calls to buy nothing.

2. The edit facade's aliases were eating another handler's vocabulary

match and replacement are the edit facade's caller-facing names for the legacy old_string / new_string pair, and the lowering rewrote them on every facade. edit_memory declares replacement itself, so remember.edit_memory's replacement text was renamed into a field that handler does not declare: the memory was edited with no replacement text and the call reported success, while the published schema advertised arguments.replacement. Only the facade that publishes the friendly pair translates it now, with a test pinning the predicate to the facade definitions so the two cannot drift.

Measured

Every reachable operation × 7 locations (top level + 6 containers) × the whole 342-name facade vocabulary — 378,094 rows — against the previous behaviour:

rows
consumed before, refused now (regressions) 0
silently dropped before, refused now 10,353
consumed rows preserved 7,547
newly reaching the handler (item 2) 7

The nine-door edit.file write probe refuses every spelling at every location with the target file byte-identical; options.workspace, which wrote to the active repository before, is now refused.

Tests

Both mechanisms are mutation-verified — narrowing the class back to four names turns TestFacadeRefusesInventedRepositorySelectorsBeforeWrite and TestFacadeInventedSelectorRefusalIsActionable red; translating the aliases everywhere again turns TestFacadeEditMemoryReceivesItsOwnReplacementVocabulary red. Plus drift pins for the alias predicate against the facade definitions and for the deliberate exclusion of path / scope.

Verification

  • go test ./internal/mcp on the merge with current main — pass
  • go test -race ./internal/mcp — pass (127 s, before the merge commit)
  • golangci-lint run ./internal/mcp/... — 0 issues
  • go build ./cmd/gortex/, git diff --check — pass

Known limit, recorded rather than implied

Fields outside the target-selector class that an operation does not consume are still forwarded and ignored. Refusing those generically needs an enumeration of every server-side reader, and the handler, the response layer and facade middleware each read the normalized arguments: output.format, options.new_user_task and options.fields are each honoured by a reader no handler schema mentions. I built that generic rule and measured it — it refused working calls in exactly those places, trading a silent-drop bug for a broken feature. docs/mcp-facade-v1.md now states the limit instead of implying a guarantee that does not hold.

Refs #549

zzet added 3 commits August 15, 2026 10:20
The repository guard matched four field names, so a caller who wrote
`options.workspace`, `options.root`, `options.cwd`, `options.repo_root`
or `options.worktree` still had it silently dropped and the operation ran
against the active repository. On edit.file that was a write to the wrong
repository reported as success — the same failure the four-name guard was
added to close, reachable by writing the intent a different way.

Inventing a name does not make the intent less clear, so it must not make
the failure quieter. The class now covers the spellings a caller may
reasonably reach for. Membership is by name, but refusal is still decided
per operation by the consumption probe, so an operation that genuinely
reads `workspace` or `root` keeps receiving it.

`path` and `scope` stay out: on this surface they name a file or a
working-tree scope far more often than a repository, and promoting them
refuses working calls to buy nothing.

Measured over every reachable operation x 7 locations x the whole 342-name
facade vocabulary, 378,094 rows, against the previous behaviour:

  rows consumed before and refused now      0
  silently dropped before, refused now  10,353
  consumed rows preserved                7,547

Fields outside this class that an operation does not consume are still
forwarded and ignored. Closing that needs an enumeration of every
server-side reader — handler, response layer, and facade middleware all
read the normalized arguments — and an incomplete one refuses working
calls: `output.format`, `options.new_user_task` and `options.fields` are
each honoured by a reader no handler schema mentions. The specification
now records that limit rather than implying a guarantee that does not hold.
…abulary

`match` and `replacement` are the edit facade's caller-facing names for
the legacy `old_string` / `new_string` pair, and the lowering rewrote them
on every facade. `edit_memory` declares `replacement` itself, so
remember.edit_memory's replacement text was renamed into a field that
handler does not declare: the memory was edited with no replacement, and
the call reported success. The public schema advertises
`arguments.replacement` for that operation, so this was the server
ignoring something it had promised to honour.

Translate only on the facade that publishes the friendly pair. Elsewhere
`match` and `replacement` are a handler's own vocabulary and must arrive
unchanged. A test pins the predicate to the facade definitions so the two
cannot drift apart.

Found by sweeping the whole facade vocabulary against every operation and
asking, for each field, whether the value reached the handler under a name
the handler declares. These seven rows — replacement at every location on
remember.edit_memory — were the only ones where the answer was no while
the schema said yes.
…selector-coverage

* 'main' of github.com:zzet/gortex:
  perf(mcp): presize the physical-evidence read from the observed size
  test(mcp): pin the physical-evidence confinement wiring and non-blocking open
  fix(mcp): judge the physical-evidence secret gate on the hashed buffer
  fix(codex): bound the `codex --version` probe with a deadline
  fix(churn): stop spawning a process per file to test path existence
  fix(mcp): harden physical read evidence
  fix(mcp): stabilize physical read evidence
  fix(mcp): keep agent tool list under budget
  feat(mcp): add physical file read evidence
@zzet
zzet merged commit 3f03c6d into main Aug 15, 2026
11 checks passed
@zzet
zzet deleted the fix/facade-target-selector-coverage branch August 15, 2026 09:15
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.

1 participant