Skip to content

fix(security): gate spoofable X-Forwarded-Host + the vm-create config path - #38

Open
Ailcope wants to merge 196 commits into
Showdown76py:mainfrom
Ailcope:fix/security-hardening
Open

Ailcope wants to merge 196 commits into
Showdown76py:mainfrom
Ailcope:fix/security-hardening

Conversation

@Ailcope

@Ailcope Ailcope commented Jul 31, 2026 •

Copy link
Copy Markdown
Collaborator

A re-audit of main at 87caf1a, after the MCP Apps panels (#34), the update notifier + self-update tools (#37) and passkey sign-in (#36) landed. Two findings, one commit each, plus a post-review revision of the first (commit 3) after @Showdown76py caught that its trusted-proxy branch could not open under uvicorn's proxy-headers middleware.

Why

A fresh read of src/ on the current main -- not a diff of one branch. The surface held up as it did in #24: OAuth PKCE S256 is mandatory with TOTP replay protection, redirect_uri is allowlisted at both /authorize and the DCR register endpoint, SQL is parameterised throughout, client_id isolation is intact on conversations / usage / tokens / DCR slugs, shell construction is quoted or argv (pct / qm via shlex.quote, the QEMU agent via argv, ipmitool via argv + IPMI_PASSWORD), the _staging_path traversal guard is sound, sessions are AES-GCM, and the new MCP-Apps panel relay is correctly gated -- its sandboxed iframe carries no cookie or CSRF token, so panel_call_allowed on the server is the boundary, and it authorises nothing the model could not already call without a modal.

Two seams were left open, one of which #24 flagged by name and deferred.

_issuer() trusted X-Forwarded-Host unconditionally. #24 listed this under Known and left alone -- OAuth discovery URLs poisonable in theory, but the client sets its own Host and server.allowed_hosts covers /mcp. The seam is the inconsistency: X-Forwarded-For is already validated against server.trusted_proxies in ratelimit.client_ip, while X-Forwarded-Host was believed from any peer -- and since then the same header also builds the "paste this MCP URL" strings on the tokens / connectors pages. On a directly-exposed deployment, or behind a proxy that does not strip a client-supplied value, the header is attacker-set.

The confirmation gate covered proxmox_vm_config(updates=...) but not proxmox_vm_create(config=...). #24 added _CONFIRM_WHEN_ARG_PRESENT for exactly this shape -- a tool that reads without an arg and mutates with it. proxmox_vm_create forwards its config dict straight to the PVE API, and a config can carry hookscript (a script PVE runs on VM lifecycle events) or raw QEMU args: code execution on the PVE host. An injected instruction in a chat turn could create + start such a VM with no modal, while the equivalent proxmox_run was blocked -- the same "exec through the side door" shape #24 closed for proxmox_write_file.

Changes

X-Forwarded-Host trust (ratelimit.py, __main__.py, dashboard/app.py)

  • New ratelimit.forwarded_host(), mirroring client_ip's trust model: X-Forwarded-Host is honoured only when the direct peer is a declared trusted_proxy, otherwise the request's own Host header wins. A chain takes the last entry (the nearest proxy's value), so a proxy that appends cannot hand back a client-supplied prefix -- symmetric with client_ip's right-to-left walk.
  • Applied to _issuer and the three dashboard host builders (_resolve_mcp_url, the tokens page, connectors_mint).
  • uvicorn.run(proxy_headers=False) (commit 3). uvicorn defaults to proxy_headers=True / forwarded_allow_ips="127.0.0.1", so its ProxyHeadersMiddleware rewrites scope["client"] to the X-Forwarded-For client before the app runs -- request.client.host is then never the declared proxy, the trusted-proxy branch can never open, and _issuer() would advertise http://127.0.0.1:8420 behind any proxy that does not preserve Host. The app already owns its forwarded-header trust end to end (client_ip for XFF, forwarded_host for XFH, both on the real peer) and reads x-forwarded-proto directly, so uvicorn must not interpret the forwarded headers for us.
  • Deliberately host-only. X-Forwarded-Proto is left untouched: a TLS-terminating edge (Cloudflare Tunnel, nginx) legitimately needs it to report https even when trusted_proxies is unset, so gating it would silently downgrade the Secure cookie flag and the OAuth issuer to http. WebAuthn origin / RP-ID derivation is also left as-is: I looked at threading trusted_proxies through passkeys.py for consistency, but it runs on the register / authenticate path and the browser binds the assertion to the real origin -- a spoofed host breaks a login rather than forging one -- so it is a separate, testable change across every passkey call site, not something to fold quietly into a security fix.

Confirmation gate (dashboard/chat.py)

  • proxmox_vm_create added to _CONFIRM_WHEN_ARG_PRESENT, keyed on config, mirroring the proxmox_vm_config / updates treatment. A bare create -- no config, or an empty {} -- stays unattended; a create carrying a non-empty config raises the modal. config is a declared parameter of the tool, so reading it is sound.

Known and left alone

  • Host validation on /oauth/* and /app/* -- allowed_hosts is enforced only by the MCP transport (server.py, TransportSecuritySettings), so a direct request with a forged Host still poisons the issuer without any X-Forwarded-Host at all. This PR closes the header inconsistency; the poisoning itself only fully goes away with an explicit server.public_url anchoring scheme + host, which is a config addition rather than a patch.
  • Panel allow-set -- panel_call_allowed falls through to the confirmation gate, so a panel can no longer call proxmox_vm_create with a config on its own. No shipped panel does, so nothing breaks today, but it is a real narrowing of the relay's allow set and belongs on the record.
  • X-Forwarded-Proto trust -- load-bearing for TLS-terminating edges, as above; the server.public_url anchor is the real fix.
  • proxmox verify_ssl / BMC verify_tls default false -- BMCs and homelab PVE ship self-signed; fix(security): close findings from a full security audit #24 made verify_tls parse for real and documented it. Flipping the default is an operator decision.
  • /metrics unauthenticated -- unchanged since fix(security): close findings from a full security audit #24: network-ACL-controlled, no label leaks a secret.
  • SSH known_hosts unset accepts any key -- the documented trusted-LAN default; per-host strict_host_key_checking is already available for public targets.

Related

Follows the audit in #24: closes its deferred _issuer / X-Forwarded-Host item and extends the _CONFIRM_WHEN_ARG_PRESENT mechanism it introduced. Re-audits the surface added by #34 (MCP Apps panels), #37 (self-update tools) and #36 (passkeys).

Tests

475 passed, 1 skipped (+3), ruff check src/ tests/ clean, Python 3.12.

test_forwarded_host_only_trusts_declared_proxy (tests/test_ratelimit.py) pins the trust model -- an untrusted peer's X-Forwarded-Host is dropped, a trusted proxy's is believed, the last entry of a chain wins, and the Host fallback holds; test_forwarded_host_through_a_real_starlette_request drives the helper through an actual Starlette Request built from an ASGI scope, so the hand-built request cases cannot drift from runtime again; test_vm_create_with_config_needs_confirmation (tests/test_dashboard_chat.py) asserts a config-bearing create gates while a bare create does not.

Showdown76py and others added 30 commits April 16, 2026 14:25
29 MCP tools across 3 modules:
- Proxmox (18 tools): monitoring, VM/CT lifecycle, storage, network, command execution
- SSH (4 tools): direct host access with sync/async exec pattern
- iLO (7 tools): HP hardware management via SSH tunnel through pve1

Features:
- Modular architecture with conditional tool registration
- QEMU Guest Agent + LXC exec auto-detection for in-VM commands
- Async command execution with polling for long-running operations
- Infrastructure context via YAML resource and MCP prompt
- Environment-based config with graceful degradation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- tests/test_integration.py: ~50 tests across all modules (Proxmox monitoring,
  system, exec QEMU/LXC, VM lifecycle, SSH, iLO, resources, error handling).
  Runs against real infrastructure with --section filtering and --test-vmid
  for destructive VM lifecycle tests.
- README.md: full installation guide in French covering server-side setup
  (API tokens, QEMU Guest Agent, SSH, iLO), .env config, Claude Code
  integration, tool reference, and troubleshooting.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds shields.io badges (Python, MCP, Proxmox, iLO, license, Claude Code),
a features highlight section, concrete usage examples showing diagnostic
workflow, and a license/footer section.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- __main__.py: dual transport (stdio + --http mode with Streamable HTTP)
  Bearer token auth via TARKAMCP_AUTH_TOKEN, /health endpoint
- deploy/tarkamcp.service: systemd unit for running on Proxmox nodes
- deploy/install.sh: one-command install script with auto-generated token
- README: connection instructions for Claude (Code/web/mobile), ChatGPT,
  Gemini (CLI/API), Cloudflare Tunnel setup, new badges
- .env.example: added TARKAMCP_AUTH_TOKEN, PORT, HOST vars

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Claude web's MCP connector doesn't support bearer tokens (only OAuth,
which is optional). Auth token is now optional:
- If TARKAMCP_AUTH_TOKEN is set: bearer token required (ChatGPT, Gemini)
- If not set: open access, rely on Cloudflare Zero Trust for security

Updated README with accurate Claude web connector instructions matching
the actual "Add custom connector" dialog.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Bearer tokens don't work with Claude web (only supports OAuth, which is
overkill for personal use). New approach: the secret is embedded in the
URL path itself (/s/<secret>/mcp), like webhook URLs.

- Any request to a path without the secret gets a 404
- The URL is the credential -- treat it like a password
- Works with ALL platforms (Claude, ChatGPT, Gemini) with zero auth config
- TARKAMCP_SECRET env var replaces TARKAMCP_AUTH_TOKEN

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Implements a real OAuth 2.1 client_credentials flow:
- CLI: `tarkamcp auth create/list/revoke` to manage client credentials
- OAuth metadata at /.well-known/oauth-authorization-server
- Token endpoint at POST /oauth/token (client_credentials grant)
- Bearer token validation on all MCP endpoints
- Tokens expire after 24h, clients re-authenticate automatically

Works with all platforms:
- Claude web/mobile: Client ID + Secret in the OAuth fields
- ChatGPT/Gemini: POST /oauth/token to get a bearer token
- Claude Code: unchanged (stdio, no auth needed)

New files:
- src/tarkamcp/auth.py: ClientStore (JSON) + TokenStore (in-memory)
- Updated __main__.py with auth subcommands and OAuth endpoints

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
TarkaMCP is now exclusively a remote HTTP server:
- `tarkamcp serve` (or bare `tarkamcp`) starts the HTTP server
- `tarkamcp auth create/list/revoke` manages OAuth clients
- No more --http flag, no more stdio transport
- Greatly simplified README: single installation flow, no dual-mode docs
- Updated CLAUDE.md to reflect HTTP-only architecture

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- tarkamcp.service: replace non-existent --http flag with serve subcommand
  (the CLI only exposes serve/auth; --http caused the service to fail on boot).
- proxmox/system.py: pass guest-agent exec `command` as an array instead of
  the invented arg0/arg1 form. The previous encoding caused PVE to drop all
  arguments, running only the binary path.
- proxmox/system.py: remove the non-existent /nodes/{node}/lxc/{vmid}/exec
  call and return actionable guidance (use ssh_exec_command + pct exec).
  Applied to both sync and async paths; the async path no longer leaves LXC
  sessions stuck in "running" forever.
- proxmox/system.py: guard proxmox_storage_status against entries missing the
  "storage" key.
- proxmox/vms.py: use /status/reboot for both QEMU and LXC (PVE has no
  /status/restart for LXC).
- proxmox/vms.py: clone uses `hostname` for LXC and `name` for QEMU, matching
  the PVE API schema.
- ssh/client.py: retain a strong reference to the asyncio.create_task used
  for background SSH runs so the task can't be garbage-collected mid-run.
- tests/test_integration.py: align the LXC exec test with the new
  API-unsupported error contract.
Logic / correctness
- proxmox_list_nodes no longer duplicates members in a real cluster. PVE
  returns the full cluster view from any reachable member, so we stop at the
  first successful GET /nodes and only fall back to other configured nodes
  when the first is unreachable. Unreachable nodes are still reported so
  stale credentials are visible.
- Bound _exec_sessions and _ssh_sessions: completed sessions older than 1h
  are pruned whenever a new one is inserted, avoiding unbounded growth on a
  long-running server.
- Removed dead code ProxmoxClient._resolve_node.

Auth / OAuth hardening
- ClientStore.verify now uses hmac.compare_digest for constant-time hash
  comparison.
- ClientStore._save writes atomically (tmp + replace) with chmod 0600 so the
  secret-hash file is never world-readable.
- ClientStore._load surfaces a clear error on malformed JSON instead of
  crashing opaquely inside json.loads.
- OAuth metadata no longer advertises response_types_supported: ["token"],
  which is meaningless for the client_credentials grant.

Misc
- ilo/client.py: asyncio.get_running_loop() (get_event_loop is deprecated
  when a loop is already running) + drop pointless f-string.
- README: correct the iLO section (the SSH tunnel is always used, not a
  direct connection) and fix the LXC update example since the API has no
  LXC exec endpoint -- the AI uses ssh + pct exec.
Install script now provisions python3/python3-venv, creates a dedicated
virtualenv at /opt/tarkamcp/.venv, installs a /usr/local/bin/tarkamcp
wrapper, and the systemd unit launches the server via `serve` from the
venv instead of the system python with the removed `--http` flag.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Always refresh apt and install python3-venv plus the versioned variant
(python3.X-venv) required on Debian. Recreate the venv when bin/pip is
absent instead of trusting the directory's existence, so a half-created
venv from a prior run no longer breaks the install.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Claude Web and other interactive MCP connectors require the authorization
code flow with PKCE. Previously only client_credentials was supported,
causing /authorize to return 401 and the connector to fail.

- Add /oauth/authorize endpoint enforcing PKCE S256 and https redirect_uri
  (localhost allowed for development).
- Extend /oauth/token to support grant_type=authorization_code with
  one-time code consumption bound to client_id, redirect_uri, and PKCE
  code_verifier. client_credentials remains for script/server use.
- Update OAuth metadata to advertise both grants, code response type,
  and S256 as the only PKCE method.
- Explicit /oauth/register endpoint returns 403 with a clear message:
  dynamic client registration is disabled, clients must be provisioned
  via `tarkamcp auth create`.
- Filter form values to str to avoid UploadFile leaking into credential
  checks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The FastMCP streamable-HTTP app initializes its session manager task
group via its own lifespan. When it is Mount()ed under a parent
Starlette, only the parent's lifespan runs, so requests to /mcp failed
with "Task group is not initialized. Make sure to use run()."

Wrap the child lifespan in the parent's and pass it via lifespan=.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Without an explicit TransportSecuritySettings, the MCP SDK defaults to
only allowing localhost Host headers, which returned 421 Misdirected
Request for any request hitting the public hostname (e.g. mcp.example.com).

Expose TARKAMCP_ALLOWED_HOSTS and TARKAMCP_ALLOWED_ORIGINS (comma
separated) so operators declare their public host(s). Defaults keep
localhost working and preauthorize Claude/ChatGPT/Gemini origins.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The DNS-rebinding allowlist is now a required-in-prod env var: without
the public hostname declared, every request returns 421. Call it out in
the Cloudflare section, the .env reference block, and the troubleshooting
table. Also document the 'unauthorized' error on /authorize when the
client ID has not been provisioned.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
python-hpilo hardcodes the literal string "localhost" as a signal to
switch to ILO_LOCAL mode — it shells out to /sbin/hponcfg and ignores
host, port, login and password. Because we tunnel via SSH and pointed
hpilo.Ilo at "localhost", every iLO tool error was "hponcfg not
installed" on machines without the HPE utility.

Use 127.0.0.1 instead so the RIBCL over HTTPS path is taken through the
tunnel. No other change needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a second authentication factor (Google Authenticator / Authy
compatible TOTP) on top of client_id/client_secret. The server is
publicly exposed via Cloudflare Tunnel and client_secret leaks would
let an attacker pilot the Proxmox infra end-to-end; 2FA closes that.

Enrollment
  `tarkamcp auth create` now also generates a base32 TOTP seed and
  prints the otpauth:// URI plus an ASCII QR code to scan once into the
  authenticator app. Seed is stored plaintext in clients.json (0600) —
  TOTP validation requires the original secret, not a hash.

Browser flow (Claude Web, ChatGPT MCP)
  /oauth/authorize is split into GET (renders an HTML form asking for
  the 6-digit code, with hidden inputs preserving the original OAuth
  params) and POST (revalidates params, validates TOTP, then issues the
  authorization code). The connector never sees the popup — it's
  rendered during the redirect, like a consent screen.

Non-interactive flow (client_credentials for ChatGPT curl / Gemini)
  /oauth/token now requires a `totp` field alongside client_id and
  client_secret for client_credentials. Missing or invalid TOTP returns
  invalid_grant. The authorization_code grant does not require TOTP
  because the code itself proves the user passed 2FA at /authorize.

Hardening
  5 failed TOTP attempts per client_id trigger a 5-minute lockout
  (in-memory counter, reset on first success). pyotp's valid_window=1
  tolerates ±30 s clock drift. The HTML form uses html.escape on all
  dynamic fields and keeps PKCE S256 mandatory.

Migration
  clients.json entries without a totp_secret are filtered at load,
  logged to stderr, and removed from disk. Operators must recreate
  every existing client after upgrade — there is no backwards-compat
  path.

Docs
  README updated: new 2FA section in the create step, popup mention for
  Claude, `totp=` added to every curl/Gemini example (with a warning
  against embedding the seed in code), new troubleshooting rows for
  invalid_grant / lockout / silent revocation after upgrade.

Deps
  pyotp and qrcode added to pyproject.toml.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Claude Web probes /.well-known/oauth-protected-resource and
/.well-known/oauth-protected-resource/mcp before running the OAuth
flow, per MCP 2025-06-18. We did not serve those paths, so they fell
through to the authenticated MCP mount and returned 401, which caused
Claude to show "unauthorized" even though everything else worked.

- Add a /.well-known/oauth-protected-resource handler (plus the
  resource-scoped /mcp variant) that returns RFC 9728 metadata pointing
  at ourselves as the authorization server.
- Whitelist both paths in auth_middleware.
- Emit WWW-Authenticate: Bearer realm=..., resource_metadata=<url> on
  401 responses so unauth'd clients can discover the AS per spec.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Lets the model voluntarily shorten the lifetime of its own access token
once a task is finished. Reduces the replay window if a token leaks,
and forces the next session to go through the full OAuth + 2FA flow.

Plumbing:
- ContextVar ``current_bearer_token`` in tarkamcp.auth, set by the HTTP
  auth middleware around call_next and cleared in a finally block.
- TokenStore gains a ``revoke(token)`` method (single-use pop from the
  in-memory dict).
- The middleware registers its TokenStore via ``register_token_store``
  so the tool reaches it without circular imports.
- New module ``tarkamcp.security.tools`` exposes the MCP tool
  ``security_end_session`` which calls ``revoke_current_token()``
  and returns ``{"revoked": bool, ...}``. The docstring tells the
  model to call it at the very end of a task, never mid-flow.
- ``server.py`` registers the new tool alongside the Proxmox/SSH/iLO
  modules.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Revoking the token synchronously from inside an MCP tool was racing
against the streamable-HTTP response: the JSON-RPC result goes back to
the client via an SSE stream that re-enters the auth middleware, so by
the time the client tried to read the tool's response the bearer was
already gone and Claude showed "Authentication required" before the
answer surfaced.

TokenStore.revoke now pulls the token's expires_at down to now + 30s
instead of popping it immediately, giving the in-flight response and
any immediate follow-up enough time to complete. The standard
validate() path enforces the new deadline, so the second a fresh
request arrives past the window it is rejected as before.

The security_end_session tool advertises the grace period in its
response so the model knows the token isn't dead instantly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
8s is enough for the streamable-HTTP response plus any immediate SSE
follow-up, and it closes the replay window faster after the model
calls security_end_session.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bundle the 512×512 WebP logo in src/tarkamcp/assets/logo.webp and expose
it to clients via FastMCP(icons=[...]) as an inline data URL. Data URL
avoids having to open a public static route through the Cloudflare
tunnel and keeps the icon available whatever reverse-proxy config is
used. Also set website_url to the GitHub repo so clients that surface
it can link home.

The asset is force-included in the wheel so `pip install -e .` and
real installs both ship it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Captures the brainstormed design: OAuth-backed login with 90-day session
cookie, Gemini 3 chat with MCP tool integration, SQLite persistence,
light/dark responsive UI, and scoped security + testing plan.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Introduces src/tarkamcp/dashboard with SQLite-backed sessions encrypting
the client_secret with AES-GCM, double-submit CSRF, and Starlette routes
for /app/login, /app/refresh, /app/logout, plus a placeholder /app/chat.
The dashboard mounts only when GEMINI_API_KEY is set; install.sh
auto-generates TARKAMCP_SESSION_KEY on first run. 36 unit and
integration tests cover the session lifecycle and the auth flows.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wires the full chat experience:

- ConversationStore + messages DB, CRUD API under /app/api/conversations,
  SSE streaming endpoint /app/api/chat/stream.
- ChatEngine abstraction with FakeChatEngine for tests and
  GeminiChatEngine wiring google-genai with TarkaMCP exposed via
  StreamableHttpTransport + the user's bearer.
- Chat shell template (sidebar + messages + composer), mobile drawer
  layout, light/dark palette via prefers-color-scheme, inline SVG icons
  (zero Unicode emoji), minimal safe markdown renderer in chat.js.
- Auto-title after first turn, tool-call cards with args/result/duration,
  model and effort selectors persisted per conversation.
- README section documenting /app/login and /app/chat, the 24h TOTP
  refresh flow, and the SQLite schema.

Thirty-six additional tests cover the conversation CRUD, SSE events,
tool-call persistence, session-expired re-prompt, and turn history.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The real API model names are gemini-3-flash-preview and
gemini-3.1-pro-preview. The initial commit used the shorter aliases
which return 404 on generateContent. Schema migrates to v2 and rewrites
stored model IDs on existing conversations and messages so past chats
keep resolving.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Google's remote-MCP-server tool (McpServer) is preview-gated and rejects
standard API keys with 403 PERMISSION_DENIED. The google-genai SDK also
supports a second mode where we open a local MCP ClientSession from the
dashboard process and hand it to the SDK as a tool; Gemini then sees the
tools as regular function declarations and the SDK orchestrates
call/response, bypassing the preview restriction.

- GeminiChatEngine now uses httpx.AsyncClient + streamable_http_client
  + ClientSession against the dashboard's own 127.0.0.1:<port>/mcp
  endpoint with the user's bearer, then passes the session to the SDK
  via tools=[session]. All orchestration is fully async.
- Adds gemini-2.5-flash and gemini-2.5-pro to VALID_MODELS for users
  without preview access; defaults to gemini-2.5-flash.
- Thinking config now branches: Gemini 3 uses thinking_level enum
  (MINIMAL/LOW/MEDIUM/HIGH); Gemini 2.5 uses thinking_budget in tokens
  mapped from the same four effort names.
- Three new unit tests cover the thinking-config branching.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Showdown76py and others added 18 commits July 29, 2026 04:37
…b_actions/docker/build-push-action-7

chore(ci): bump docker/build-push-action from 6 to 7
…b_actions/actions/checkout-7

chore(ci): bump actions/checkout from 4 to 7
…b_actions/actions/setup-python-7

chore(ci): bump actions/setup-python from 5 to 7
…b_actions/docker/setup-buildx-action-4

chore(ci): bump docker/setup-buildx-action from 3 to 4
…gate

The new dry_run escape in _tool_call_requires_confirmation was applied to
every gated tool, but only the three snapshot tools declare the parameter.
FastMCP validates arguments with a plain pydantic model, so extra='ignore'
silently drops an undeclared dry_run server-side: ssh_run with
command="rm -rf /" and dry_run=True skipped the modal and then ran for
real. That is a bypass of the pre-existing ssh_run / proxmox_run gate, and
it is reachable through exactly the injected instruction the gate exists
to stop.

Restrict the escape to _DRY_RUN_AWARE. Two tests pin it: the bypass shape
on six tools that lack the parameter, and a check that every allow-listed
tool really declares dry_run so the list cannot drift.

Also skip the dashboard.db permission test on Windows, where os.chmod only
toggles the read-only bit.
The example config gained a third BMC device to document verify_tls, and
the config job enumerates the template's ${VAR} references explicitly, so
validate-config rejected the unset one and the job went red.
* docs: split the README into focused guides

The README had grown to 359 lines and covered installation, the full config
reference, the tool catalogue and the security checklist all at once. Two of
the tool section headers were also wrong (VM lifecycle said 7 for 14 entries,
system said 3 for 8) and the overview claimed "30+ tools" where the server
registers 44.

Keep the README as a landing page: what it is, a Docker quick start, a table
of contents, the architecture diagram, and a short warning before pointing a
model at real infrastructure. Everything else moves out:

- docs/installation.md   requirements, Docker, systemd, wizard, proxy, updates
- docs/configuration.md  the two files, YAML keys grouped by section
- docs/tools.md          all 44 tools with correct per-module counts
- docs/security.md       tool-call review, token handling, TOTP hygiene

The Assistant connector steps only existed in the README, so they move into
docs/clients.md next to the other clients. totp-automation.md linked to the
now-gone README#connecting-clients anchor three times; those point at
clients.md and security.md instead.

* @
docs: address review — security tool is always on, TOTP seed guidance

`register_security_tools(mcp)` runs unconditionally in server.py, so the
tools.md intro was wrong to imply every module disappears with its
capability. Spell out that `security_end_session` is always registered.

installation.md also told readers to park the raw TOTP seed in a secrets
manager, which security.md and clients.md both forbid — it collapses the
second factor. Align it and point at the automation doc instead.
@
mcp 2.0.0 shipped and renamed mcp.server.fastmcp to mcp.server.mcpserver,
along with FastMCP to MCPServer. Every tool module imports the old path,
so pip resolving the open-ended >=1.0 to 2.0 broke collection of four test
modules on main and on every open PR.

Cap at <2 to get CI reporting again. 1.29.0 is the newest 1.x and still
ships mcp.server.fastmcp, so the ceiling costs nothing today. Migrating to
the 2.0 API is separate work.
Showdown76py#31 split the README into docs/, which collided with this branch's edit to
the confirmation paragraph in the old monolithic file. Take the split
README as-is and move the substance to its new home in docs/security.md:
the gate now covers the write and destructive tools, not just ssh_run and
proxmox_run.

Also correct docs/dashboard.md, which still described dry_run as exempting
any gated tool. That was the behaviour dd0cfd6 removed, so the sentence now
names the three snapshot tools and says why the argument alone is not
evidence.
fix(deps): hold mcp below 2.0 until the MCPServer rename is done
fix(security): close findings from a full security audit
…howdown76py#36)

* feat(auth): passkey sign-in and a confirmation step on both login pages

Adds WebAuthn as an alternative second factor on /app/login and
/oauth/authorize. A passkey replaces the TOTP code, never the client
secret: both pages keep the client_id + client_secret step first, so a
stolen passkey is useless on its own -- and the dashboard session has to
encrypt the secret anyway to re-mint MCP bearers later.

Both pages now confirm before moving on, instead of redirecting the
instant 2FA clears:

* the validate button shimmers while the request is in flight, and
  Enter submits as soon as the sixth digit lands;
* the screen that follows shows when access expires, offers to enrol a
  passkey on this device, and waits for "Finish signing in".

On /oauth/authorize that also fixes a latent papercut: the authorization
code is minted at "Finish", not before, so its 60 s OAuth 2.1 lifetime is
no longer burned while a human reads the page. The approval is held as a
single-use in-memory ticket instead.

Both flows stay fully functional with JavaScript off -- the forms POST
and the confirmation panel renders server-side.

Storage is a new `passkeys` table (schema v5) holding a credential id, a
public key and a signature counter; challenges are in-memory and
single-use. Credentials are listed and removable from /app/tokens.
Dynamically-registered clients delegate to their owner's passkeys, the
same way they already delegate TOTP.

py_webauthn is imported lazily: without it the pages simply hide every
passkey affordance and TOTP remains the only way in. Same when the
browser has no secure context (plain-HTTP LAN deployments).

Tests drive the real ceremonies against a software ES256 authenticator
built in the test module, covering wrong origin, wrong RP ID, replayed
challenge, foreign credential, counter regression and the CSRF rotation
that signing in performs.

* fix(passkeys): say why passkeys are unavailable instead of hiding silently

When the second factor cleared, the confirmation screen simply had no
"Add a passkey" button and no explanation. Two independent gates can
switch the feature off and neither was surfaced anywhere:

* server side, the optional `webauthn` package may not be installed
  (a fresh `git pull` without `pip install -e .` is enough);
* browser side, WebAuthn is only exposed on a secure context, so a
  plain-HTTP origin has no API to call.

Now:

* the boot banner prints `Passkeys:  enabled` or `disabled - <reason>`;
* `beaconmcp doctor` gained a Passkeys section naming the fix command;
* the pages render a short hint when the *server* offers passkeys but the
  *browser* refuses them, instead of dropping the button with no trace.

Hiding the affordance from an anonymous visitor is still right -- there
is nothing they could do about it -- but the operator now has three
places to ask the question and get an answer.
…P tools (Showdown76py#37)

* feat(updates): tell signed-in operators about updates, and offer to apply them

BeaconMCP cuts no releases and ships no PyPI package: the canonical install
is a git clone with a venv and a systemd unit. So "is there an update?"
means "is this checkout behind the upstream default branch?", and nothing
in the server was answering that question. Operators found out by
happening to read the repo.

Adds three things.

**A notice, for signed-in operators only.** A card on any /app/* page when
the checkout is behind: how far, the recent commit subjects, a link to the
diff, and the commands to update. GET /app/api/update requires a live
session and 401s otherwise -- the exact revision a server runs is free
reconnaissance for anyone who hasn't authenticated, and the card is only
ever rendered to someone signed in. Dismissing it hides that revision
until a newer one lands.

**Instructions that match the install**, rather than assuming everyone ran
deploy/install.sh. A git checkout gets its own root and its real venv pip
path, plus a systemctl line only when a unit file actually exists; a
container gets docker compose; a pip distribution gets the git+https URL.

**Two MCP tools.** beaconmcp_check_update is read-only. beaconmcp_self_update
applies: pull --ff-only, reinstall dependencies, validate the config, then
restart. It requires confirm=True, refuses a dirty checkout so local edits
are never discarded, and refuses a non-git install.

The config validation is a hard gate, not a warning, and it is what makes
this safe to run unattended: it shells out to `beaconmcp validate-config`
so the *new* code parses the operator's *actual* config. If a setting was
renamed or a new one is now required, the checkout is reset to where it
started, dependencies are restored, and nothing is restarted -- an update
that bricks the server is worse than no update.

The check also diffs the incoming .env.example / beaconmcp.yaml.example
against the operator's real files (not the local examples, and honouring
variables already exported), so the notice can say "this update wants a
variable you haven't set" *before* it is applied.

The dashboard's "Update now" re-prompts for 2FA: pulling code and
restarting is the most privileged thing the panel can do, so a session
alone is not the right bar -- same gate as minting a token.

Both are switchable: features.updates.enabled is the air-gap switch (no
egress, no tools, no notice) and allow_self_update keeps the notice while
forbidding the apply, for deployments where updates go through a pipeline.

Also fixes __version__, which had been pinned at "0.1.0" while pyproject
said 2.0.0 -- it now reads package metadata, with the real number as the
source-tree fallback.

Tests drive git for real against throwaway repositories: a mocked
subprocess would only prove the mock agrees with itself. pip and the
validation subprocess are the two steps stubbed, so the pull/validate/
roll-back orchestration is exercised without touching the interpreter
running the suite.

* fix(updates): mention updates on the post-2FA screen, and stop caches pinning old assets

Two gaps found by actually looking at the rendered pages.

**The "You're signed in" screen said nothing.** The toast fetches its
status once at page load, which on /app/login happens before the session
exists -- so it 401'd and stayed empty, and signing in never re-checks
because it does not reload the page. The one moment the operator is
guaranteed to pass through said nothing about a pending update.

login.js now re-asks once the session is created and renders a one-line
mention above "Finish signing in". Deliberately not the full card: that
screen has a single primary action, and on a narrow viewport a
bottom-anchored card this tall would sit on top of it. The card now opts
out of the auth pages entirely and shows on the landing page instead.

**Browsers could keep running the previous release's JavaScript.**
Starlette serves static files with ETag/Last-Modified but no
Cache-Control, which leaves browsers on heuristic freshness -- a file
untouched for weeks is reused for a long time without ever revalidating.
That was survivable when upgrading meant running commands by hand; it is
not once the server can update itself and the next page load is expected
to match the new backend. This was not theoretical: it bit the browser
used to verify the change, which kept executing a stale bundle across
several restarts.

Asset URLs now carry a fingerprint of the bundle, recomputed at start
from the newest mtime in the static directory (which a git pull bumps).
New bytes mean a new URL, so no cache can serve it from an old entry --
which also lets the files be cached hard instead of revalidated:

  ?v= present  -> public, max-age=31536000, immutable
  ?v= absent   -> no-cache (a legacy or hand-typed URL can't pin old code)
  /app/* pages -> no-store (per-session, and they carry the fingerprint)

* fix(updates): serialize update work, and keep the restart off the shell

Self-review findings on the update flow.

**Two updates could run at once.** The dashboard button and the MCP tool
reach `apply_update` independently, so nothing stopped a second one
starting mid-pull: two `git pull` / `pip install -e .` runs in one
checkout fight over index.lock and can leave a half-applied tree, and one
caller's rollback could discard the other's successful update. A second
caller is now told an update is already running rather than queued behind
a pip that may take minutes -- it never touches git.

**Cold-cache checks stampeded.** Every dashboard tab opening at once fired
its own `git fetch`, piling up 60 s subprocesses for one answer. The
uncached path is now single-flighted; waiters get the result the winner
cached.

**The deferred restart built a shell string.** `service` is the literal
"beaconmcp" today, so this was not exploitable, but interpolating it into
`sh -c` means a future change that made the unit name configurable would
silently become a shell injection. Values now go through argv.

All three are covered by tests, and both locks were mutation-checked:
removing either makes its test fail (4 concurrent checks instead of 1;
"release unlocked lock" when the second updater proceeds).
* feat(proxmox): interactive VM panel via the MCP Apps extension

proxmox_vm_panel carries _meta.ui.resourceUri pointing at a ui:// resource
served as text/html;profile=mcp-app, which an Apps-capable client renders in
a sandboxed iframe: live CPU/RAM/disk, start/stop/restart, and fields for
core count and memory.

Runs on mcp 1.x. The Apps class that wraps this lives in 2.0 and needs
MCPServer, but the two knobs it sets -- meta= on the tool, mime_type= on the
resource -- are already on FastMCP, so the panel does not wait on that
migration.

The panel holds no cluster access of its own. Its buttons issue ordinary
tools/call requests for proxmox_vm_start / _stop / _restart / _config, so the
client's approval prompt still stands in front of every action. Clients that
skipped the extension ignore _meta.ui and get the same snapshot as data,
which is why the tool returns the full state rather than a placeholder.

Verified against a harness that speaks the host side of the protocol:
handshake, initial render, power actions with refresh, config apply sending
only changed keys, tool errors surfacing without wedging the controls, and
the theme switch.

* fix(panel): send ui/initialize params flat, as the host expects

The handshake nested appCapabilities under a `capabilities` key and sent
`appInfo` as `clientInfo`. The real shape, per the ext-apps App.connect()
implementation, is flat: appInfo / appCapabilities / protocolVersion.

A rejected handshake is silent -- the host simply does not reply. So the
promise never settled, `ui/notifications/initialized` never went out, the
host never delivered `ui/notifications/tool-result`, and the panel sat on
"Loading..." with an empty frame and nothing in the console. That is what
showed up in Claude: the host reported the widget as rendered while the
iframe stayed blank.

Also surface the failure instead of hanging on it. A handshake that goes
unanswered for 5s now replaces the spinner with the reason, so the next
protocol mismatch is one glance rather than an afternoon.

The browser harness this was first tested against replied to any
ui/initialize it received, which is why the bad shape passed. It now
validates the params like a host does, and the new test pins the flat
shape against the shipped HTML -- it fails on the old file.

* feat(panels): log viewer, cluster dashboard, and model-context sync

Three additions on top of the VM panel.

proxmox_logs_panel renders a node's syslog or task history as a scrollable
list with level and substring filters, error and warning lines coloured, and
a fullscreen request. Logs are the worst thing to read through a chat
transcript: the model summarises them and the lines you wanted are gone.

cluster_overview_interactive is cluster_overview as a browsable panel --
node cards with CPU and memory pressure, a searchable guest table with
inline start/stop, storage pools with usage bars. It reuses the aggregators'
collection helpers rather than re-querying Proxmox its own way.

Both panels, and now the VM panel, push state back with
ui/update-model-context after an action. Without it the model keeps whatever
the tool returned when the panel opened, so stopping a VM from the panel
left the next turn believing it was still running. Where the host advertises
ui/message, the VM panel offers an "ask about this guest" button and the
dashboard an "open" button per row; both are hidden when it does not.

Three panels meant three copies of the JSON-RPC bridge, so it moves to
apps/bridge.js with the shared look in apps/panel.css, spliced in at the
<!--mcp-runtime--> marker when the resource is read. Each panel still ships
as one self-contained document.

Verified in a browser against a harness that validates the handshake the way
a host does: rendering, filters, source switching, inline power actions with
reload, context updates and messages arriving with the right shapes, and the
graceful path when the host advertises neither capability.

* feat(auth): passkey sign-in + confirmation step on both login pages (Showdown76py#36)

* feat(auth): passkey sign-in and a confirmation step on both login pages

Adds WebAuthn as an alternative second factor on /app/login and
/oauth/authorize. A passkey replaces the TOTP code, never the client
secret: both pages keep the client_id + client_secret step first, so a
stolen passkey is useless on its own -- and the dashboard session has to
encrypt the secret anyway to re-mint MCP bearers later.

Both pages now confirm before moving on, instead of redirecting the
instant 2FA clears:

* the validate button shimmers while the request is in flight, and
  Enter submits as soon as the sixth digit lands;
* the screen that follows shows when access expires, offers to enrol a
  passkey on this device, and waits for "Finish signing in".

On /oauth/authorize that also fixes a latent papercut: the authorization
code is minted at "Finish", not before, so its 60 s OAuth 2.1 lifetime is
no longer burned while a human reads the page. The approval is held as a
single-use in-memory ticket instead.

Both flows stay fully functional with JavaScript off -- the forms POST
and the confirmation panel renders server-side.

Storage is a new `passkeys` table (schema v5) holding a credential id, a
public key and a signature counter; challenges are in-memory and
single-use. Credentials are listed and removable from /app/tokens.
Dynamically-registered clients delegate to their owner's passkeys, the
same way they already delegate TOTP.

py_webauthn is imported lazily: without it the pages simply hide every
passkey affordance and TOTP remains the only way in. Same when the
browser has no secure context (plain-HTTP LAN deployments).

Tests drive the real ceremonies against a software ES256 authenticator
built in the test module, covering wrong origin, wrong RP ID, replayed
challenge, foreign credential, counter regression and the CSRF rotation
that signing in performs.

* fix(passkeys): say why passkeys are unavailable instead of hiding silently

When the second factor cleared, the confirmation screen simply had no
"Add a passkey" button and no explanation. Two independent gates can
switch the feature off and neither was surfaced anywhere:

* server side, the optional `webauthn` package may not be installed
  (a fresh `git pull` without `pip install -e .` is enough);
* browser side, WebAuthn is only exposed on a secure context, so a
  plain-HTTP origin has no API to call.

Now:

* the boot banner prints `Passkeys:  enabled` or `disabled - <reason>`;
* `beaconmcp doctor` gained a Passkeys section naming the fix command;
* the pages render a short hint when the *server* offers passkeys but the
  *browser* refuses them, instead of dropping the button with no trace.

Hiding the affordance from an anonymous visitor is still right -- there
is nothing they could do about it -- but the operator now has three
places to ask the question and get an answer.

* feat(updates): update notice for signed-in operators + self-update MCP tools (Showdown76py#37)

* feat(updates): tell signed-in operators about updates, and offer to apply them

BeaconMCP cuts no releases and ships no PyPI package: the canonical install
is a git clone with a venv and a systemd unit. So "is there an update?"
means "is this checkout behind the upstream default branch?", and nothing
in the server was answering that question. Operators found out by
happening to read the repo.

Adds three things.

**A notice, for signed-in operators only.** A card on any /app/* page when
the checkout is behind: how far, the recent commit subjects, a link to the
diff, and the commands to update. GET /app/api/update requires a live
session and 401s otherwise -- the exact revision a server runs is free
reconnaissance for anyone who hasn't authenticated, and the card is only
ever rendered to someone signed in. Dismissing it hides that revision
until a newer one lands.

**Instructions that match the install**, rather than assuming everyone ran
deploy/install.sh. A git checkout gets its own root and its real venv pip
path, plus a systemctl line only when a unit file actually exists; a
container gets docker compose; a pip distribution gets the git+https URL.

**Two MCP tools.** beaconmcp_check_update is read-only. beaconmcp_self_update
applies: pull --ff-only, reinstall dependencies, validate the config, then
restart. It requires confirm=True, refuses a dirty checkout so local edits
are never discarded, and refuses a non-git install.

The config validation is a hard gate, not a warning, and it is what makes
this safe to run unattended: it shells out to `beaconmcp validate-config`
so the *new* code parses the operator's *actual* config. If a setting was
renamed or a new one is now required, the checkout is reset to where it
started, dependencies are restored, and nothing is restarted -- an update
that bricks the server is worse than no update.

The check also diffs the incoming .env.example / beaconmcp.yaml.example
against the operator's real files (not the local examples, and honouring
variables already exported), so the notice can say "this update wants a
variable you haven't set" *before* it is applied.

The dashboard's "Update now" re-prompts for 2FA: pulling code and
restarting is the most privileged thing the panel can do, so a session
alone is not the right bar -- same gate as minting a token.

Both are switchable: features.updates.enabled is the air-gap switch (no
egress, no tools, no notice) and allow_self_update keeps the notice while
forbidding the apply, for deployments where updates go through a pipeline.

Also fixes __version__, which had been pinned at "0.1.0" while pyproject
said 2.0.0 -- it now reads package metadata, with the real number as the
source-tree fallback.

Tests drive git for real against throwaway repositories: a mocked
subprocess would only prove the mock agrees with itself. pip and the
validation subprocess are the two steps stubbed, so the pull/validate/
roll-back orchestration is exercised without touching the interpreter
running the suite.

* fix(updates): mention updates on the post-2FA screen, and stop caches pinning old assets

Two gaps found by actually looking at the rendered pages.

**The "You're signed in" screen said nothing.** The toast fetches its
status once at page load, which on /app/login happens before the session
exists -- so it 401'd and stayed empty, and signing in never re-checks
because it does not reload the page. The one moment the operator is
guaranteed to pass through said nothing about a pending update.

login.js now re-asks once the session is created and renders a one-line
mention above "Finish signing in". Deliberately not the full card: that
screen has a single primary action, and on a narrow viewport a
bottom-anchored card this tall would sit on top of it. The card now opts
out of the auth pages entirely and shows on the landing page instead.

**Browsers could keep running the previous release's JavaScript.**
Starlette serves static files with ETag/Last-Modified but no
Cache-Control, which leaves browsers on heuristic freshness -- a file
untouched for weeks is reused for a long time without ever revalidating.
That was survivable when upgrading meant running commands by hand; it is
not once the server can update itself and the next page load is expected
to match the new backend. This was not theoretical: it bit the browser
used to verify the change, which kept executing a stale bundle across
several restarts.

Asset URLs now carry a fingerprint of the bundle, recomputed at start
from the newest mtime in the static directory (which a git pull bumps).
New bytes mean a new URL, so no cache can serve it from an old entry --
which also lets the files be cached hard instead of revalidated:

  ?v= present  -> public, max-age=31536000, immutable
  ?v= absent   -> no-cache (a legacy or hand-typed URL can't pin old code)
  /app/* pages -> no-store (per-session, and they carry the fingerprint)

* fix(updates): serialize update work, and keep the restart off the shell

Self-review findings on the update flow.

**Two updates could run at once.** The dashboard button and the MCP tool
reach `apply_update` independently, so nothing stopped a second one
starting mid-pull: two `git pull` / `pip install -e .` runs in one
checkout fight over index.lock and can leave a half-applied tree, and one
caller's rollback could discard the other's successful update. A second
caller is now told an update is already running rather than queued behind
a pip that may take minutes -- it never touches git.

**Cold-cache checks stampeded.** Every dashboard tab opening at once fired
its own `git fetch`, piling up 60 s subprocesses for one answer. The
uncached path is now single-flighted; waiters get the result the winner
cached.

**The deferred restart built a shell string.** `service` is the literal
"beaconmcp" today, so this was not exploitable, but interpolating it into
`sh -c` means a future change that made the unit name configurable would
silently become a shell injection. Values now go through argv.

All three are covered by tests, and both locks were mutation-checked:
removing either makes its test fail (4 concurrent checks instead of 1;
"release unlocked lock" when the second updater proceeds).

* feat(dashboard): render MCP Apps panels in the integrated chat

Closes Showdown76py#35. The ui:// panels from Showdown76py#34 only rendered in external hosts;
/app/chat showed the tool's JSON. The dashboard is both the MCP client
and the host, so both halves were missing.

Not blocked by mcp 2.0 after all. A client declares Apps support through
ClientCapabilities.extensions, which 1.x has no attribute for -- but the
model is declared extra="allow", so the field serialises under the name
the spec gives it and the server reads the same JSON either way. The pin
costs the typed attribute, not the capability.

Client half (dashboard/mcp_bridge.py, chat.py): an AppsClientSession that
tags the outgoing InitializeRequest rather than reimplementing
initialize(), the tool -> ui:// map read off each tool's _meta, and the
full CallToolResult carried on ToolCallEnd -- the panel needs the whole
payload, not the 500-char preview the tool card shows.

Host half (chat.js, two routes): the document is served by
/app/api/mcp/panel under its own CSP and framed with sandbox="allow-
scripts" and no allow-same-origin. Verified in a browser: the frame gets
a SecurityError on document.cookie and on window.parent, and CSP blocks
fetch. Its only way out is postMessage, which is what makes the parent
page the place where policy is decided. chat.js answers ui/initialize,
pushes tool-input/tool-result, relays tools/call through
/app/api/mcp/call, routes ui/message into a real turn and
ui/update-model-context into the next one, and honours size-changed and
request-display-mode.

The confirmation question Showdown76py#35 raised, decided: a panel button is a human
click on a labelled control, so it is not gated -- but "calls from an
iframe skip the gate" is not the rule. A ui:// document is HTML the
server wrote and this dashboard is a general MCP host, so the exemption
is a closed list enforced in panel_call_allowed(): start/stop/restart on
one guest, and proxmox_vm_config only for sizing keys (exempting the tool
would exempt hookscript, raw QEMU args and device passthrough with it).
Everything else is refused rather than prompted, because there is no turn
in flight to hang a modal on -- and a panel that needs more sends
ui/message, which puts the request back under the modal.

Only the ui:// URI is persisted with a tool call, never the snapshot: a
panel reopened from history refetches rather than showing week-old
figures in a live-looking frame.

Also fixes the panels' theming against a real host: panel.css now reads
the spec's standardized variable names with its own values as fallbacks,
so hostContext.styles.variables actually lands.

505 passed (+44), ruff clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(dashboard): move the model picker to Gemini 3.6 Flash / 3.5 Flash-Lite / 3.1 Pro

Gemini 3.6 Flash went GA on 2026-07-21 and supersedes gemini-3-flash-preview;
3.5 Flash-Lite is the Flash-Lite that shipped alongside it, and lands at the
price 2.5 Flash used to hold. Gemini 2.5 Flash / Pro and gemini-3-flash-preview
leave the picker; 3.1 Pro stays as the preview option.

There is no gemini-3.6-flash-lite -- the Lite in that launch is 3.5.

Rates (AI Studio, 2026-07-29): 3.6 Flash $1.50/$0.15/$7.50, 3.5 Flash-Lite
$0.30/$0.03/$2.50. 3.1 Pro is unchanged. The retired models keep their entries
in _PRICING: cost_usd re-prices stored turns, so dropping a rate would silently
re-bill that history at the fallback model's price.

Schema 6 moves conversations off the retired ids -- conversations.model is what
the *next* turn runs on, and a retired id there would fail VALID_MODELS and
silently fall back, reading as the picker forgetting the operator's choice.
messages.model is deliberately left alone: it records which model actually
wrote a reply, which is history rather than configuration. That is the
difference from migration 2, which renamed the same model.

Also fixes an unrelated fragility in test_fingerprint_changes_when_an_asset_changes:
it bumped app.css past its own mtime, but the fingerprint is the directory
maximum, so the assertion failed whenever another static file happened to be
newer.

510 passed, ruff clean. Picker verified in the browser: chip reads "3.6 Flash",
groups Flash / Pro, 3.1 Pro carries the Preview badge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(dashboard): gate beaconmcp_self_update behind the confirmation modal

Found reviewing this branch before merge. Enumerating the 49 registered
tools against _NEEDS_CONFIRMATION turned up beaconmcp_self_update sitting
outside it: with confirm=True it runs git pull, reinstalls dependencies
and restarts the service, so one injected instruction in a log line could
replace the process that enforces the gate. It landed ungated with the
self-update tools in Showdown76py#37; the panel relay added here would have inherited
the hole.

_CONFIRM_WHEN_ARG_PRESENT rather than _NEEDS_CONFIRMATION: confirm=False
only previews. Reading the argument is sound here because `confirm` is a
parameter the tool declares -- the trap the dry_run note describes is an
argument the tool does *not* declare, which pydantic drops during
validation.

Adds a test that walks every @mcp.tool in src/ and fails on any name that
is neither gated nor on an explicit reviewed-as-safe list, so the next
tool cannot land outside the gate unnoticed.

Also restores the composer text when submit() fails before the message is
rendered -- moving the clear ahead of sendUserText() for ui/message made a
failed conversation-create eat what the operator typed.

513 passed, ruff clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
X-Forwarded-Host is attacker-controlled on a direct request, but the
issuer builder, the dashboard host resolution, the tokens/connectors
pages and the mint flow all trusted it unconditionally -- mirroring the
X-Forwarded-For handling that client_ip() already gates on
trusted_proxies.

Add ratelimit.forwarded_host(), which honours X-Forwarded-Host only when
the direct peer is a declared trusted proxy and otherwise falls back to
the request's own Host header. Route every host-building call site through
it. Scheme handling (X-Forwarded-Proto) is deliberately left untouched: a
TLS-terminating edge (Cloudflare tunnel, nginx) legitimately needs it to
report https even with trusted_proxies unset, and gating it would silently
downgrade the Secure-cookie flag and the OAuth issuer to http.
proxmox_vm_create forwards its `config` dict straight to the PVE API. A
config can carry code-execution keys -- `hookscript` (a script PVE runs on
VM lifecycle events) or raw QEMU `args` -- so an injected instruction in a
chat turn could create+start a VM that runs code on the host, without ever
hitting the approval modal that gates ssh_run / proxmox_run /
proxmox_vm_config(updates=...).

Add proxmox_vm_create to _CONFIRM_WHEN_ARG_PRESENT keyed on `config`,
mirroring the proxmox_vm_config `updates` treatment: `config` is a declared
parameter, so reading it is sound, and gating on its presence leaves the
bare no-config create (a harmless empty shell) unattended.
Copilot AI review requested due to automatic review settings July 31, 2026 15:41

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Hardens BeaconMCP’s reverse-proxy trust model and the dashboard chat approval gate by (1) only honoring X-Forwarded-Host when the direct peer is a configured trusted proxy, and (2) requiring human confirmation when proxmox_vm_create is invoked with a config payload.

Changes:

  • Added ratelimit.forwarded_host() and switched dashboard/mint/issuer URL construction to use it with trusted_proxies.
  • Extended the chat approval gate so proxmox_vm_create(config=...) triggers confirmation, plus updated gating coverage tests.
  • Added tests covering the new forwarded-host trust behavior and the vm-create-with-config confirmation behavior.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
src/beaconmcp/ratelimit.py Adds forwarded_host() to gate X-Forwarded-Host on trusted direct proxies.
src/beaconmcp/dashboard/app.py Uses forwarded_host() when constructing externally-facing URLs in the dashboard.
src/beaconmcp/__main__.py Uses forwarded_host() for OAuth issuer construction.
src/beaconmcp/dashboard/chat.py Adds proxmox_vm_create to the “confirm when arg present” gate.
tests/test_ratelimit.py Adds unit tests for forwarded-host trust behavior.
tests/test_dashboard_chat.py Adds tests for vm-create-with-config confirmation + updates tool gating classification list.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 870 to 872
scheme = request.headers.get("x-forwarded-proto", request.url.scheme)
host_hdr = request.headers.get(
"x-forwarded-host", request.headers.get("host", "localhost"),
)
host_hdr = forwarded_host(request, deps.trusted_proxies)
url = f"{scheme}://{host_hdr}/mcp/c/{row.slug}"
Comment thread src/beaconmcp/dashboard/chat.py Outdated
Comment on lines +207 to +212
# ``proxmox_vm_create`` is here for the same reason: a bare create is a
# harmless empty shell, but a ``config`` dict can carry code-execution keys
# -- ``hookscript`` (a script PVE runs on VM lifecycle events) or raw QEMU
# ``args`` -- which an injected instruction could set to run code on the PVE
# host. ``config`` is a declared parameter of the tool, so reading it is
# sound; gating only on its presence keeps the no-config create ungated.
@Ailcope
Ailcope requested a review from Showdown76py July 31, 2026 16:19

@Showdown76py Showdown76py left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed both commits against main@87caf1a, checked the claims in the code, ran the suite locally (510 passed, ruff check src/ tests/ clean) and reproduced the runtime behaviour of the new helper through uvicorn's actual middleware. CI is 6/6 green; mergeable_state: blocked is the required review, not a failure.

Both findings are real. The vm-create one I would merge as is. The X-Forwarded-Host one needs a change before it does what it says.

proxmox_vm_create gate: good. config is splatted straight into the PVE API call, hookscript and raw QEMU args are host code execution, and the codebase had already written that reasoning down for proxmox_vm_config in _PANEL_CONFIG_KEYS while leaving the same dict ungated one tool over. The gate is additive, the guard test move is coherent. One undocumented side effect on the panel relay, noted inline.

X-Forwarded-Host gate: the trusted-proxy branch cannot open in the running server. uvicorn.run() defaults to proxy_headers=True / forwarded_allow_ips="127.0.0.1", and its ProxyHeadersMiddleware rewrites scope["client"] to the XFF client before the app sees the request. So request.client.host is the public client IP, never the declared proxy, and forwarded_host drops X-Forwarded-Host even for a proxy listed in trusted_proxies. Verified against the real middleware:

with uvicorn proxy-headers (default ON): client=203.0.113.5  forwarded_host=127.0.0.1:8420
without the middleware:                  client=127.0.0.1    forwarded_host=beacon.example.com

It fails closed, so nothing is less safe. But on a deployment whose proxy does not preserve Host, _issuer() now advertises http://127.0.0.1:8420 in the OAuth metadata and clients cannot complete discovery. The fake _Req objects in tests/test_ratelimit.py bypass uvicorn, which is why the suite stays green. Fix is one argument to uvicorn.run (proxy_headers=False, or forwarded_allow_ips fed from trusted_proxies), plus a test through the real ASGI stack.

Two smaller points inline: the left-most chain entry contradicts client_ip's right-most walk, and trusted_proxies is documented as XFF-only in three places that this commit silently widens.

Worth stating plainly about the security value: allowed_hosts is enforced only on the MCP transport, so a direct request with a forged Host still poisons the issuer without any X-Forwarded-Host involved. The inconsistency this closes is real, but the poisoning itself needs the server.public_url anchor already listed under "Known and left alone". The passkeys.py deferral is sound: the browser binds the assertion to the real origin, so a spoofed host breaks a login rather than forging one.

Suggestion: land commit 2 now, revise commit 1 for the uvicorn interaction first.


Generated by Claude Code

Comment on lines +201 to +204
client = getattr(request, "client", None)
direct_peer = getattr(client, "host", None) if client is not None else None
direct_ip = _coerce_ip(str(direct_peer)) if direct_peer is not None else None
if direct_ip and _is_trusted_proxy(direct_ip, trusted_proxies):

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This branch cannot open in the running server, and the fallback can break OAuth discovery.

__main__.py:2246 calls uvicorn.run(app, host=host, port=port, log_level="info"). uvicorn defaults to proxy_headers=True with forwarded_allow_ips="127.0.0.1", and its ProxyHeadersMiddleware rewrites scope["client"] to the client address taken from X-Forwarded-For whenever the direct peer is loopback. So by the time this function runs, request.client.host is the public client IP, never the proxy, and _is_trusted_proxy returns False even for a proxy that is correctly declared in trusted_proxies.

Reproduced against the real middleware (same-host nginx, trusted_proxies=("127.0.0.1", "::1")):

with uvicorn proxy-headers (default ON): client=203.0.113.5  forwarded_host=127.0.0.1:8420
without the middleware:                  client=127.0.0.1    forwarded_host=beacon.example.com

Failing closed is fine for the attack case, but it also means a declared proxy is never believed. On a deployment whose proxy does not preserve Host (proxy_pass without proxy_set_header Host $host;) _issuer() now returns http://127.0.0.1:8420, so /.well-known/oauth-authorization-server advertises an unreachable issuer and MCP clients stop being able to complete the flow. That is a behaviour change for existing installs, not just hardening.

The tests do not catch it because _Req in tests/test_ratelimit.py is a hand built object that never goes through uvicorn.

Two options, either works:

  • uvicorn.run(..., proxy_headers=False). The app already implements its own XFF trust model in client_ip, and every scheme read goes through the x-forwarded-proto header directly rather than request.url.scheme alone, so nothing else regresses.
  • uvicorn.run(..., forwarded_allow_ips=",".join(config.server.trusted_proxies)) and keep uvicorn as the single owner of that decision.

Either way it would be worth one test that drives a request through the real ASGI stack, so the fake-request tests cannot drift from runtime behaviour again.


Generated by Claude Code

Comment thread src/beaconmcp/ratelimit.py Outdated
Comment on lines +207 to +208
# A proxy chain may append entries; the first is the client-facing host.
first = fwd.split(",")[0].strip()

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor, but this is the one place where the docstring's "mirrors client_ip's trust model" does not hold. client_ip walks the chain right to left precisely so a client supplied left-most entry cannot win when a proxy appends. Here the left-most entry is returned, so a proxy that appends to X-Forwarded-Host rather than overwriting it hands the attacker value straight back.

Most proxies do overwrite (nginx proxy_set_header X-Forwarded-Host $host;, Traefik, Caddy), so this is a small residual, but the test at tests/test_ratelimit.py pins the left-most choice as intended behaviour. Either take the last entry for symmetry with client_ip, or say in the docstring that the trusted proxy is assumed to overwrite rather than append.


Generated by Claude Code

Comment thread src/beaconmcp/__main__.py
Comment on lines +1211 to 1213
host_header = forwarded_host(
request, tuple(config.server.trusted_proxies),
)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two notes on the call site itself, both about scope rather than correctness.

trusted_proxies now governs how the issuer host is derived, not just X-Forwarded-For, and the failure mode when it is unset is a wrong discovery URL rather than a coarser rate-limit key. The three places that document the field still describe it as XFF only, so they drift with this commit: beaconmcp.yaml.example:44 ("Trust X-Forwarded-For only from direct peers you operate"), docs/configuration.md:39, docs/cloudflare.md:127.

Second, worth stating in the PR description so the residual is not read as closed: allowed_hosts is only enforced by the MCP transport (server.py:129, TransportSecuritySettings), so /oauth/* and /app/* have no Host validation at all. An attacker reaching the server directly can still send Host: evil.example and get the same poisoned issuer out of this function, without touching X-Forwarded-Host. The header inconsistency this commit closes is real, but the poisoning itself only really goes away with the server.public_url anchor listed under "Known and left alone".


Generated by Claude Code

_CONFIRM_WHEN_ARG_PRESENT: dict[str, str] = {
"proxmox_vm_config": "updates",
"beaconmcp_self_update": "confirm",
"proxmox_vm_create": "config",

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This one is right and I would take it as is.

The mechanism holds: proxmox_vm_create is not in _NEEDS_CONFIRMATION, so the early return in _tool_call_requires_confirmation adds a gate rather than replacing a stronger one, and vms.py:135 does create_params = config or {} then client.post(..., **create_params), so the dict really does reach the PVE API untouched. The repo had already reached this conclusion for the same keys one tool over: the _PANEL_CONFIG_KEYS note below names hookscript and raw QEMU args as the reason proxmox_vm_config cannot be panel exempt, while the identical dict was going through vm_create unattended. Closing that is consistent.

One side effect that is not in the description and is worth a line there: panel_call_allowed falls through to _tool_call_requires_confirmation, so a panel can no longer call proxmox_vm_create with a config on its own. No shipped panel does, so nothing breaks today, but it is a real change to the relay's allow set.


Generated by Claude Code

@Ailcope

Ailcope commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator Author

The uvicorn finding is right, and it's the one that matters -- I wrote and tested forwarded_host against a hand-built _Req and never drove it through the ASGI stack it actually runs behind, so I missed that ProxyHeadersMiddleware gets there first. On defaults (proxy_headers=True, forwarded_allow_ips="127.0.0.1") scope["client"] is already rewritten to the XFF client by the time _issuer runs, so request.client.host is never the declared proxy and the trusted-proxy branch can't open. It fails closed, but "closed" here means _issuer() hands back http://127.0.0.1:8420 on any proxy that doesn't preserve Host -- a discovery regression for existing installs, not hardening. Confirmed on my side: __main__.py:2246 passes neither flag, so uvicorn owns the rewrite.

I'll take proxy_headers=False over forwarded_allow_ips. The app already owns its forwarded-header trust end to end -- client_ip does its own right-to-left XFF walk and every scheme read goes through the x-forwarded-proto header directly -- so letting uvicorn also rewrite client just gives two owners for one decision. Turning it off puts request.client.host back to the real TCP peer, which is exactly what both client_ip and forwarded_host are written to expect. Plus a test through the real ASGI stack so the fake-_Req cases can't drift from runtime again.

On the chain walk: you're right, take the last entry, not the first. client_ip walks right-to-left precisely so an appended client value can't win, and returning the left-most X-Forwarded-Host hands it straight back on any proxy that appends instead of overwriting. Last entry restores the symmetry and collapses to the same value on the overwrite case, so there's no downside.

Docs drift: agreed -- trusted_proxies now governs the issuer host, not just XFF, so the three call-outs (beaconmcp.yaml.example:44, docs/configuration.md:39, docs/cloudflare.md:127) need to say so rather than describing it as XFF-only.

Residual stated plainly, into the description: allowed_hosts only runs on the MCP transport, so /oauth/* and /app/* take a forged Host directly -- the header consistency this closes is real, but the poisoning itself only goes away with the server.public_url anchor still under Known and left alone. Same for the panel side effect you flagged on commit 2: panel_call_allowed now falls through the create-with-config gate, so a panel can't mint that call on its own -- no shipped panel does, but it's a real narrowing of the relay allow-set and belongs in the description, not just the diff.

Two smaller ones:

  • passkeys.py:_forwarded_host() -- I'll thread trusted_proxies through it for consistency while I'm in the helper, but I agree with your read that it's cosmetic rather than a fix: the browser binds the assertion to the real origin, so a spoofed host breaks a login rather than forging one.
  • chat.py comment -- the truthiness is intended (an empty config is a bare shell, no hookscript/args, nothing to gate), but "presence" is the wrong word for it; I'll say "a non-empty config" so the comment matches bool(args.get("config")).

Plan matches your suggestion: commit 2 stands as is, I'll revise commit 1 (proxy_headers=False + last-entry + docs + a real-ASGI test) and repush, with the two residual lines added to the description. Happy to split them into separate PRs if you'd rather land the vm-create gate first -- your call.

The trusted-proxy branch added in 6b531df could never open in the running
server. uvicorn.run defaults to proxy_headers=True /
forwarded_allow_ips="127.0.0.1", so ProxyHeadersMiddleware rewrites
scope["client"] to the X-Forwarded-For client before the app runs, and
request.client.host is never the declared proxy. It failed closed, but
"closed" meant _issuer() advertised http://127.0.0.1:8420 on any proxy that
does not preserve Host -- an OAuth discovery regression, not hardening. The
hand-built _Req tests missed it by bypassing the ASGI stack.

- __main__: uvicorn.run(proxy_headers=False). The app already owns its
  forwarded-header trust (client_ip for XFF, forwarded_host for XFH, both on
  the real peer) and reads x-forwarded-proto directly, so uvicorn must not
  pre-rewrite the peer.
- ratelimit.forwarded_host: take the last X-Forwarded-Host entry, not the
  first, so a proxy that appends cannot hand back a client-supplied prefix --
  symmetric with client_ip's right-to-left walk.
- tests: drive forwarded_host through a real Starlette Request so the
  hand-built _Req cases cannot drift from runtime again.
- docs: trusted_proxies now governs the advertised host too, not just XFF
  (beaconmcp.yaml.example, configuration.md, cloudflare.md).
- chat.py: tighten the vm-create gate comment -- the check is bool(config), so
  an empty {} is ungated like a bare create.
@Ailcope

Ailcope commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed 8ec9a58. I kept commit 2 untouched and layered the commit-1 revision on top rather than rewriting it, so the "good as is" one stays a clean standalone -- squash on merge and the XFH story collapses to one change either way.

  • uvicorn.run(proxy_headers=False). Confirmed your repro from the other side: on defaults request.client.host is the XFF client, so the trusted-proxy branch never opened and _issuer() fell back to http://127.0.0.1:8420. Turning the middleware off puts the real peer back, which is what both client_ip and forwarded_host are written to expect -- and it's the more correct state anyway, since it makes client_ip's own trusted-proxy walk actually reachable instead of leaning on uvicorn to have done it.
  • forwarded_host now takes the last X-Forwarded-Host entry, not the first, per your chain-walk point -- a proxy that appends can't hand the client-supplied prefix back, and the overwrite case collapses to a single entry so nothing changes there.
  • Docs: fixed the three call-outs (beaconmcp.yaml.example, docs/configuration.md, docs/cloudflare.md) so trusted_proxies reads as governing the advertised host, not just XFF.
  • The _Req gap: added a test that drives forwarded_host through a real Starlette Request from an ASGI scope, so the fake requests can't quietly drift from runtime again.
  • Description now carries the two residuals plainly -- allowed_hosts only running on the MCP transport (so /oauth/* and /app/* take a forged Host directly, and the real close is the server.public_url anchor), and the panel allow-set narrowing on commit 2.

On passkeys -- I said I'd thread trusted_proxies through _forwarded_host there while I was in it, and on second look I'm leaving it. It sits on the register/authenticate path, and doing it safely means plumbing trusted_proxies through every passkey call site, RP-ID derivation included, where getting it subtly wrong breaks logins rather than hardening anything -- and by your own read the spoof only breaks a login, never forges one. That's a standalone change with its own test surface, not a rider on this one. Happy to do it as a follow-up PR if you want the consistency.

475 passed, 1 skipped, ruff check src/ tests/ clean, Python 3.12. Commit 2 still stands as is -- merge order your call.

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.

4 participants