Skip to content

fix: durable server tool baseline so replaced inventories never auto-approve (Spec 117 PR-1; #1567) - #1604

Merged
Dumbris merged 3 commits into
mainfrom
fix/117-pr1-durable-baseline
Oct 12, 2026
Merged

Dumbris merged 3 commits into
mainfrom
fix/117-pr1-durable-baseline

Conversation

@Dumbris

@Dumbris Dumbris commented Oct 12, 2026

Copy link
Copy Markdown
Member

Scope

Spec 117 PR-1 only (#1567): a durable per-server tool baseline, so a replaced inventory, an authoritative empty refresh or an empty or blind review never re-triggers the first-baseline auto-approval of tools nobody reviewed. Review binding / expected_hashes (PR-2, #1566) and capture ordering (PR-3, #1565) are not in this PR.

The Spec 117 design (specs/117-review-bound-approvals/, from draft #1580) ships with this PR. ROADMAP.md is regenerated.

The bug on main. A trusted server auto-baselines read_note. Its inventory is then replaced by the destructive wipe_all_notes. The removal pass deletes read_note's record, so the next pass finds no approved record. It treats the server as new and approves wipe_all_notes by auto-baseline, and call_tool_destructive then reaches the upstream. An authoritative empty refresh, or a server approval of an empty review, leads to the same result.

Design

  • Storage: a new bucket tool_baseline_decisions keyed by server name. Each value is {decided_at, source: discovery|server_approval|inferred, by}.
    • Writes are idempotent. The first decided_at is kept, and only an inferred source is upgraded.
    • An undecodable value counts as decided (fail closed).
    • A read never creates the bucket.
    • The marker is removed only in the same bbolt transaction as DeleteServerToolApprovals (server removal) or PruneToolApprovalsNotIn (orphan prune, which also covers markers of servers that have no records).
  • internal/toolreview: a new leaf package (stdlib only) that holds BaselineDecision. It avoids the storage → scanner import cycle.
  • Runtime:
    • serverHasBaseline = marker OR an approved/changed record. The record inference is kept, so no migration is needed and a downgrade is safe.
    • resolveToolBaselineHeld lists the prior records and reads the marker. On an older store (records but no marker) it back-fills inferred before the pass's first record write.
    • A listing error, a marker read error or a back-fill write error returns errBaselineMarker. The pass is then aborted with no write of any kind. On main, a listing error was read as "no baseline", which auto-baselined the whole inventory.
    • A pass that started without a baseline and left an approved record writes discovery after its loop. A failure there is logged and is not an abort: the records it just approved establish the baseline by inference, and the next pass back-fills the marker. An empty first inventory writes no marker (FR-025).
  • Server approval: Runtime.CommitServerApproval(server, blocks, expected, by, commit) is the PR-1 form of the Spec 117 commit section. Under the per-server approval lock it writes the marker (server_approval, by) and then runs the scanner's integrity-baseline save.
    • The scanner reaches it through the optional ServerApprovalCommitter capability of its unquarantiner adapter, for both the plain branch and the …WithBlocks branch. This covers empty and blind reviews and scan-auto.
    • If the marker cannot be written, the approval fails before anything is committed.
    • A non-nil expected is rejected; binding is PR-2.
  • Copy (FR-026): the Web blind-approval dialog and the CLI blind prompt now say that tools the server lists later will wait for review.
  • Docs: a new "Durable tool baseline" section in docs/features/security-quarantine.md, plus docs/cli/review-commands.md and a CLAUDE.md Recent Changes line.

Deviations from the spec

These are recorded in research.md, "PR-1 implementation notes". Main gained Spec 116 D15 after the design: discovery now publishes the snapshot, stamp and StateView inside the approval-lock hold, through held.

  1. The baseline step runs before held. When it fails, nothing of the capture is published, so T105's "restore the last-good snapshot and stamp" is unnecessary. commitDiscoveredToolsAt reports such a pass as eligible but not published, and the sweep re-lists it. Spec 116's per-server sweep publish replaces the batch RefreshToolsFromDiscovery omission the design described.
  2. No eviction on abort. The abort precedes every decision, so the records and the index still describe the last applied capture and its holds.
  3. A marker read error aborts the pass (fail closed), as T105 states, rather than only counting as "has baseline".
  4. The discovery marker's by is the ApprovedBy of the first approved record: auto-baseline, or auto, scan-approved or auto-approve-changes under a lifted gate.
  5. Fault injection uses a runtime test hook (testHooks.toolBaselineFault), because Runtime.storageManager is the concrete *storage.Manager rather than an interface.

Lock order

No new lock edges:

  • Discovery: captureSnapMu → per-server approval lock → storage (the marker read/back-fill happens inside the existing hold, before held).
  • Server approval: per-server approval lock → storage (marker tx) → the scanner's storage commit. This replaces the previous WithToolApprovalLock hold, which covered the with-blocks branch only.
  • The unquarantine (QuarantineServer(false) → Runtime.Commit) still runs after unlock, as before.

This composes with the per-server approval lock (#1571), Spec 116's commit section (#1590) and the TPA RT-01..07 holds (#1596). No RT path changed. Scan mode still never auto-approves a changed contract, and a new tool in scan mode still goes through the scan.

Compatibility

  • One additive bucket and no record schema change. An older binary ignores the bucket; a newer one back-fills inferred on its first pass (verified live on an older store).
  • Behaviour change (A6/O1): after a blind or empty-review security approval, tools the server lists later are held for review instead of being auto-baselined. Unquarantine paths that are not a review approval (REST unquarantine, the MCP op, config edits) are unchanged (A7).
  • Scan-mode servers approved by scan-auto record the marker too. New tools then follow the scan-mode rule (a clean scan still auto-approves them), not the first-baseline rule.

Verification

  • Failing-first tests (TDD), all green after the fix. Each fails on main or against a compiling stub:
    • internal/storage/tool_baseline_decision_test.go (T102)
    • internal/runtime/tool_baseline_durable_test.go (T104 a–e, T106)
    • internal/runtime/tool_baseline_marker_fault_test.go (T104f: replacement and authoritative-empty aborts, a hint update that writes nothing, a listing fault, a read fault, a discovery-marker fault that is not an abort)
    • internal/server/tool_baseline_durable_dispatch_test.go (T104s). On origin/main this test fails: refreshes 2 and 3 approve wipe_all_notes by auto-baseline, the call returns "ok" and the stub upstream records the dispatch.
    • internal/security/scanner/service_baseline_commit_test.go (T106)
    • frontend/tests/unit/review-screen-blind-copy.spec.ts and the CLI prompt test (T108)
  • Race lane: go test -race -tags server -timeout 30m -skip "E2E|Binary|MCPProtocol|TestInfoEndpoint|TestGracefulShutdownNoPanic|TestSocketInfoEndpoint" ./internal/... passes in all 80 packages. The new tests pass with -race -count=5. go test ./cmd/mcpproxy/ passes, and the vitest review specs pass 32/32.
  • TPA lab: go test ./tests/tpa-attack-lab/ passes.
  • API E2E: ./scripts/test-api-e2e.sh on scratch ports 19008/19009: 70 passed, 0 failed.
  • Lint: golangci-lint v2, bare and with --build-tags server, reports nothing in the changed files.
  • Build: make build succeeds.
  • Live L1 on scratch daemons, with the origin/main binary run first:
    • Main: wipe_all_notes was approved by auto-baseline, indexed and dispatched (1 upstream call, 2 after a restart). The authoritative-empty variant and the empty-review variant were also auto-approved.
    • Fix: wipe_all_notes stayed pending after 3 refreshes and after a restart. It was absent from the index search, and call_tool_destructive was refused with 0 upstream calls. The authoritative-empty and empty-review variants also stayed pending and were refused.
    • Removal: removing the server and re-adding it clears the marker, so the first inventory is auto-baselined as a new server.
    • Big profile: unchanged. The quarantined large-review keeps 180 pending tools and none are indexed, and the trusted 320-tool server stays approved.
    • Restart and upgrade: a restart keeps the marker. Restarting an older (main) store on the fix binary back-fills inferred.

Fixes #1567
Supersedes #1580 (spec)

🤖 Generated with Claude Code

…approve (Spec 117 PR-1)

A trusted server's baseline was inferred only from approved/changed tool
records. A replaced inventory or an authoritative empty refresh deletes those
records, so the next discovery pass treated the server as new and approved the
replacement by auto-baseline; call_tool_destructive then reached the upstream.

- storage: tool_baseline_decisions bucket (one marker per server), removed
  only together with the server's approval records (same bbolt tx).
- toolreview: new leaf package with the BaselineDecision value type.
- runtime: serverHasBaseline = marker OR approved/changed record; the inferred
  marker is back-filled before the pass's first write and a marker or listing
  failure aborts the pass before anything is published or written; the
  discovery marker is written after a pass that approved tools.
- runtime/scanner/server: every security approval commits through
  Runtime.CommitServerApproval, which records the marker under the per-server
  approval lock before the integrity baseline (empty and blind reviews too).
- copy: the blind-approval dialog and CLI prompt say later tools wait for
  review; docs Durable tool baseline section; Spec 117 design docs.

Related #1567
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Oct 12, 2026 •

Copy link
Copy Markdown

Deploying mcpproxy-docs with  Cloudflare Pages  Cloudflare Pages

Latest commit: 6f593e7
Status: ✅  Deploy successful!
Preview URL: https://0bf50ccf.mcpproxy-docs.pages.dev
Branch Preview URL: https://fix-117-pr1-durable-baseline.mcpproxy-docs.pages.dev

View logs

@github-actions

github-actions Bot commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

📦 Build Artifacts

Workflow Run: View Run
Branch: fix/117-pr1-durable-baseline

Available Artifacts

  • archive-darwin-amd64 (31 MB)
  • archive-darwin-arm64 (28 MB)
  • archive-linux-amd64 (19 MB)
  • archive-linux-arm64 (17 MB)
  • archive-windows-amd64 (31 MB)
  • archive-windows-arm64 (28 MB)
  • frontend-dist-pr (0 MB)
  • installer-dmg-darwin-amd64 (27 MB)
  • installer-dmg-darwin-arm64 (25 MB)
  • smart-mcp-proxymcpproxy-goL63P5S.dockerbuild (0 MB)

How to Download

Option 1: GitHub Web UI (easiest)

  1. Go to the workflow run page linked above
  2. Scroll to the bottom "Artifacts" section
  3. Click on the artifact you want to download

Option 2: GitHub CLI

gh run download 38291075769 --repo smart-mcp-proxy/mcpproxy-go

Note: Artifacts expire in 14 days.

… the StateView; correct the marker-failure docs (Spec 117 PR-1 review r1)

- New TestToolBaselineMarkerFault_CaptureIsNotPublished drives
  commitDiscoveredToolsAt with a published seed capture and a non-nil capture
  plus an injected marker write failure (hint update and replaced inventory):
  published=false and the StateView tools, ToolsDiscovered, DiscoveryEpoch,
  records, last-good snapshot and capture stamp are all unchanged. The
  existing marker-fault tests passed a nil capture and never reached the
  publication branch.
- docs/features/security-quarantine.md and CLAUDE.md: a failed marker write
  on a server's first trusted discovery is not an abort (its approved records
  establish the baseline and the next pass marks it); read/list failures and
  the back-fill write on a server with approved records abort the pass.
@codecov-commenter

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

❌ Patch coverage is 79.78142% with 37 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
internal/runtime/tool_baseline.go 75.60% 11 Missing and 9 partials ⚠️
internal/storage/tool_baseline_decision.go 84.12% 6 Missing and 4 partials ⚠️
internal/runtime/lifecycle.go 75.00% 2 Missing ⚠️
internal/runtime/tool_quarantine.go 81.81% 1 Missing and 1 partial ⚠️
internal/server/server.go 50.00% 1 Missing and 1 partial ⚠️
internal/storage/bbolt.go 66.66% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

@Dumbris
Dumbris merged commit 3473771 into main Oct 12, 2026
50 of 51 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.

security: server tool baseline is not durable; replaced inventory or empty review re-triggers auto-baseline of unreviewed tools

2 participants