Skip to content

fix: bind approvals to the reviewed tool definitions (Spec 117 PR-2; #1566) - #1605

Merged
Dumbris merged 11 commits into
mainfrom
fix/117-pr2-review-binding
Oct 12, 2026
Merged

Dumbris merged 11 commits into
mainfrom
fix/117-pr2-review-binding

Conversation

@Dumbris

@Dumbris Dumbris commented Oct 12, 2026

Copy link
Copy Markdown
Member

Scope

Spec 117 PR-2 (#1566): approvals are bound to the tool definitions the operator reviewed. PR-1 (#1604, durable baseline) is already on main; capture ordering (PR-3, #1565) is not in this PR.

  • Review fingerprint. GET /api/v1/servers/{id}/review reports current_hash per tool: rv1- + sha256 over the server, the tool, the approval-hashed contract (description, input and output schema) and the four safety hints by raw value. The rug-pull hash is unchanged. Golden vectors are pinned in internal/runtime/testdata/review_fingerprint_vectors.json.
  • Binding. POST .../security/approve and POST .../tools/approve (tools and approve_all) accept expected_hashes. Absent or null means unbound (legacy). {} binds to the empty snapshot. The check runs on records read under the per-server approval lock. A mismatch returns HTTP 409 with error_code: review_out_of_date, stale_tools: {changed, unreviewed, missing_blocks}, and a message that contains "out of date" and never "dangerous". Nothing is written.
  • Server approval in one locked step. It validates, writes the baseline marker, commits the integrity baseline and blocks, promotes the reviewed pending tools and flips storage quarantine under one hold of the server's approval lock. It does not promote everything after the unquarantine, so a tool filed after the commit stays pending. This applies to unbound requests too.
  • Clients. The Web review screen and mcpproxy review approve (new --from-review <file>) send the binding. The macOS tray (fix(tray): per-server review sheet, click-time approval decisions, review-bound approvals (#1569) #1572) already does, and its contract is honoured unchanged.

Design decisions and deviations from the spec

  1. The server approval is one Spec 116 config commit. Runtime.CommitServerApproval runs Commit("security_approve") with SetQuarantined(false) and an UnderApprovalLock hook, the integration point Spec 116 left for this spec. The hook does validate → marker → integrity baseline + blocks → promote. The commit then writes the config file and the storage row before it releases the lock. The spec's D5 asked only for the storage flip inside the section. With Spec 116 the whole flip, including the config file and the live config, is one step. FinishServerApproval is what remains: the activity record and HandleUpstreamServerChange. The planned QuarantineServer split (finishQuarantineChange) is not needed. QuarantineServer is unchanged and keeps its promote-pending behaviour for the non-review unquarantine paths (A7).
  2. A failure after the hook leaves the hook's writes in place. If the config file or storage write fails after the hook, the marker, baseline, blocks and promoted records stay while the server remains quarantined. That is fail-safe: a quarantined server exposes no tool, and a retry validates the same fingerprints. Promotion events are emitted inside the hook, as approveToolsLocked always did.
  3. FR-017 and FR-017a were already met on main by the TPA red-team fixes (fix(security): close TPA red-team holes in the rug-pull gate and detect engine (RT-01..RT-07) #1596). RT-03 folds the effective hints into the rug-pull hash, and refreshHeldContractSnapshot keeps a changed record on the live definition. The output-schema backfill also refreshes the hints. T205b and T205c are therefore regression tests, not failing-first tests. The T205c wording "still approved after the backfill" predates RT-03: a hint flip now goes down the rug-pull path. The test asserts what FR-017a protects: the review shows the captured hints, and a binding to the old hints is refused.
  4. Web force retry. The retry re-sends the block list and binding of the attempt that answered "dangerous". A review reload while the force dialog is open still drops the retry (the bug(web): ReviewScreen async handlers apply late responses to the wrong server/session and rebind blind or forced approvals #1568 hygiene already on main). T213's "even after a reload" became "when no reload intervened".
  5. Unbound call shape. A Web review without fingerprints (an older core) calls the API exactly as before, without the binding argument.
  6. CLI --from-review. It accepts the data object that review show -o json prints (the CLI drops the envelope) as well as the REST envelope. A file for another server, an error envelope or a malformed file exits 1 before any request. The trusted path binds --tools to the named tools and the default approve_all to every pending or changed tool. An empty review sends {}. review approve no longer prints cobra's usage text for a refusal from the core.
  7. A binding is never dropped silently. The scanner refuses a bound request when its unquarantiner lacks the reviewed capability. The adapter refuses one without a runtime, and the handler answers 503 when the controller cannot check it.

Lock order

configCommitMu (L0, Spec 116 commit) → per-server approval lock (L1, taken by the commit for the server; the hook runs under it) → storage / bbolt. ApproveToolsReviewed and ApproveAllToolsReviewed take only L1. Nothing under L1 takes L0 or captureSnapMu, or calls SaveConfiguration, HandleUpstreamServerChange or a reindex. Events, the reindex and FinishServerApproval run after the lock is released.

Compatibility

Client \ Core main (no fingerprint) this PR
older tray or scripts (unbound) unchanged accepted; tools filed after the approval stay pending
macOS tray #1572 unbound (no hashes in the review) bound and validated; its 409 "out of date" handling reloads
Web / CLI from this PR unbound against an older core bound

mcpproxy security approve, the Tools page bulk approve and the MCP quarantine_security approval operations stay unbound (follow-up O3). Both editions behave the same.

Verification

  • Failing-first: the runtime binding tests against stubs, and FR-012 against the pre-PR-2 unquarantine (a tool filed between commit and finish was promoted). The CLI --from-review and REST binding tests fail on main by construction. The live main run shows the field ignored with HTTP 200.
  • go test -race -tags server -timeout 30m -skip "E2E|Binary|MCPProtocol|TestInfoEndpoint|TestGracefulShutdownNoPanic|TestSocketInfoEndpoint" ./internal/...: 81 packages ok. go test -race ./cmd/mcpproxy/ ok. The new runtime tests pass with -count=5. go test ./tests/tpa-attack-lab/ ok.
  • vitest: 2606/2606 passed. make build ok. golangci-lint v2, bare and with --build-tags server: no findings in the changed code.
  • ./scripts/test-api-e2e.sh on a scratch port: 69 passed, 0 failed, 1 skipped (an external registry was unreachable).
  • Live L2 on fresh scratch daemons, main binary first:
    • On main, a stale security/approve returned 200 and activated a definition that had turned destructive. Unreviewed tools and stale tools/approve / approve_all were approved too.
    • On the fix, every one of those cases returned 409, nothing was approved and the server stayed quarantined. Fresh fingerprints approved.
    • A block of an unreviewed tool succeeded. An unbound approve still worked, and later tools stayed pending. An empty review bound to {}.
    • CLI --from-review exited 1 with the hint, and -o json printed the 409 envelope.
    • Web (Playwright): the approval request carried the binding, a 409 reloaded the review and showed the core's message with no force dialog, and the force retry re-sent the same binding.
    • The upstream saw zero tools/call.

Fixes #1566

🤖 Generated with Claude Code

…ons (Spec 117 PR-2)

The review payload reports a per-tool review fingerprint (current_hash:
rv1- over server, tool, the approval-hashed contract and the four raw
safety hints; the rug-pull hash is unchanged). ApproveToolsReviewed and
ApproveAllToolsReviewed validate expected_hashes on records read under the
per-server approval lock and return toolreview.StaleToolReviewError with
nothing written on a mismatch.

CommitServerApproval is now one Spec 116 config commit: its approval-lock
hook validates the binding, records the baseline marker, runs the
integrity baseline + blocks commit and promotes the pending records present
at that moment, and the same lock hold covers the storage quarantine flip.
FinishServerApproval runs the unlocked tail without the post-unquarantine
promote-all, so a tool filed after the commit stays pending (FR-012).

Related #1566
…review_out_of_date (Spec 117 PR-2)

The scanner's ApproveServerReviewed passes the binding to the runtime's
one-section server approval through the new ReviewedServerApprover
capability (the dangerous-scan gate still answers first); ApproveServer and
ApproveServerWithBlocks are its unbound forms. A binding is never dropped:
without the capability or a runtime it fails closed.

Both REST approve handlers accept expected_hashes (absent/null = unbound,
{} = bound to the empty snapshot) and map StaleToolReviewError to HTTP 409
{error_code: review_out_of_date, stale_tools: {changed, unreviewed,
missing_blocks}}; the message says "out of date" and never "dangerous",
as the macOS tray (#1572) expects. A bound duplicate tool name is counted
once. Swagger regenerated.

Related #1566
…view (Spec 117 PR-2)

mcpproxy review approve sends expected_hashes built from the review the
operator saw: every reviewed tool on a quarantined server ({} for an empty
review), the selected or held tools on a trusted server, unbound only when
the core reports no fingerprints. --from-review binds to a saved
'review show -o json' of the same server (checked before any request); the
interactive prompt lists the tools the approval makes callable. A 409 out of
date exits 1 with the core's message and a hint; -o json prints the
envelope. Refusals no longer print cobra's usage text.

Related #1566
… review (Spec 117 PR-2)

The review screen sends the click-time fingerprints of every reviewed tool
with a server approval ({} for an empty or not-captured review, unbound
only for a review without fingerprints) and a per-tool approve sends that
tool's fingerprint. The force retry re-sends the binding of the attempt that
answered dangerous. On 409 out of date it reloads the review, keeps the
operator's explicit choices that still apply and shows the core's message
beside the controls, never the error panel or the force dialog.

Related #1566
REST (current_hash, expected_hashes, 409 contract, compatibility matrix),
CLI review approve (--from-review, binding, exit codes), the security
quarantine page, CLAUDE.md Recent Changes, ROADMAP, and the Spec 117
tasks/research notes for PR-2 (implementation notes and deviations).

Related #1566
@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: c0866fb
Status: ✅  Deploy successful!
Preview URL: https://16d8dc04.mcpproxy-docs.pages.dev
Branch Preview URL: https://fix-117-pr2-review-binding.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-pr2-review-binding

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-goSN7DEE.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 38322049766 --repo smart-mcp-proxy/mcpproxy-go

Note: Artifacts expire in 14 days.

@codecov-commenter

codecov-commenter commented Oct 12, 2026 •

Copy link
Copy Markdown

…2 step 8 status (Spec 117 PR-2, Sol r1)

expectedHashes built the binding in a plain {} keyed by upstream-controlled
tool names; a tool named __proto__ hit the inherited setter and dropped out
of the JSON, so the core refused every approval of it as out of date. Build
it on a null prototype. T218: L2 step 8 ran through the tray client code
against the fixed core; the rendered-sheet run stays open.
…rds follow colliding and raw-hint captures (Spec 117 PR-2, Sol r2)

- reviewFingerprint hashes server, tool, CurrentHash, description, normalized
  input/output schema and raw hints, each length-prefixed: two contracts that
  share the |-joined storage hash no longer share a fingerprint. The rug-pull
  hash is unchanged (its own collision is #1606).
- refreshHeldContractSnapshot keeps a changed record only when hash AND every
  field match, and compares the raw four hints (FR-017).
- Tests: colliding contracts (named, approve-all, server approval, changed
  record), explicit-default hint, approve-all over a changed record, and a
  capture injected between validation and the writes (reviewBindingValidated
  seam).
- Spec/data-model/research/contract/tasks and REST docs: rv2.
…request schemas (Spec 117 PR-2, Sol r3)

swag v2 cannot emit a JSON Schema type union, so the two expected_hashes
fields carry an x-nullable marker and make swagger runs cmd/oas-nullable
to rewrite it into type: [object, "null"] in swagger.yaml and docs.go.
A schema-validating client now accepts the explicit null the handlers
already treat as an unbound request, and still rejects strings/arrays.
…nding

# Conflicts:
#	CLAUDE.md
#	ROADMAP.md
#	docs/api/rest-api.md
#	frontend/src/components/ReviewScreen.vue
#	frontend/src/services/api.ts
#	internal/httpapi/security_scanner.go
#	internal/httpapi/server.go
#	internal/runtime/tool_baseline.go
#	internal/security/scanner/service.go
#	internal/server/server.go
#	oas/docs.go
#	oas/swagger.yaml
…n capture (single locked approval section); resync evidence
…eplaced, not not-found (Spec 117 PR-2 review)
@Dumbris
Dumbris merged commit 942afe6 into main Oct 12, 2026
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: tool/server approvals are not bound to the reviewed definitions (add expected_hashes / review fingerprint)

2 participants