MCP security best practices: what we implement, and what we don't
Written 2026-09-06 against the MCP 2026-07-28 Security Best Practices page, which complements the MCP Authorization specification. Implementation claims reconciled on 2026-10-08 for the unified product. The OWASP MCP Top 10 assessment maps controls, deployment prerequisites, evidence, and remaining work across the agent system.
This is a conformance map, not a summary of the spec. Each section names the
attack the spec describes, states whether it applies to this server, and cites
the code that mitigates it. Where we fall short of a SHOULD, the section says
so plainly and says why — a gap an operator can read is worth more than a
claim they cannot check.
The audience is whoever is answering a security questionnaire about this server, and whoever changes the auth boundary next and needs to know which properties are load-bearing.
Summary
Section titled “Summary”| Spec section | Applies | Status |
|---|---|---|
| Evaluation boundary (additional evidence, not a normative MCP requirement) | Test harness only | Pinned SDK loop with one authenticated gateway, no provider key, inherited settings/plugins or built-ins; bounded process group and versioned digest-only manifests/metadata terminal evidence. Linux requires a root-owned, runner-group namespace helper with scoped AppArmor permission; host restrictions stay enabled, and SDK qualification remains pending. A runtime error alone cannot prove refusal; incomplete/cancelled trials have no negative verdict (runtime_tests::runtime_refusal_requires_terminal_gateway_and_destination_evidence, runtime_tests::cancelled_trial_has_a_manifest_and_no_negative_attack_result). Local scripted provider, not live-model resistance (runtime_tests::runtime_routes_both_paths_under_one_owner_with_isolated_credentials_and_tools, runtime::tests::cancellation_timeout_and_leader_exit_kill_owned_descendants); limits |
| Confused deputy | No — not an OAuth proxy | N/A by architecture |
Token passthrough (MUST NOT) |
Yes | Met, enforced by type shape |
| SSRF | Yes (control-plane fetches, downloads, Git cloning, and the stdio-only browser tools) | CI status polling retries typed transport failures within its existing schedule, re-authorizes each poll, and stops on policy refusals; vendor error text cannot enable transport retries (ci_wait_tests, ci_wait::tests). Partial; HTTP and Git destinations are bound before dispatch, browser URLs are restricted to http/https, network restrictions are deployment controls, and Git’s and the browser’s own network stacks are outside the HTTP policy boundary |
| Optional local model guard | When deployed | Explicit ONNX CPU/CUDA selection and CPU-node fallback policy; no provider-loss downgrade or execution retry. Mutually exclusive hash-locked ONNX wheels; qualify actual CUDA placement and numerical/performance behavior with the opt-in harness before deployment (test_onnx_gpu.py::test_session_provider_and_fallback_contract, test_onnx_gpu.py::test_unavailable_cuda_refuses_before_artifact_fetch). GPU drivers/libraries/device confinement and VRAM remain operator responsibilities. Liveness-aware readiness, idle heartbeats, capped backoff and lifetime restart budget; permanent configuration/artifact failures stay unavailable. Process-group cancellation/shutdown cleanup; use the dedicated-account cgroup deployment example and qualify supervisor SIGKILL/OOM containment in staging. test_supervision.py::test_idle_hang_heartbeat_and_inflight_timeout_recover, test_supervision.py::test_cancellation_during_spawn_joins_and_reaps_child, inference_guard_tests::supervised_local_guard_failures_preserve_gateway_policy. Pinned artifacts and runtime; authenticated loopback; complete subjects; bounded downloads/windows and fail-closed readiness. GPU required for Safetensors; The explicitly selected trained ONNX CPU profile has curated validation, with all artifacts SHA-256 pinned and policy actions locked by inference_guard_tests::trained_local_model_gateway_preserves_policy_actions_and_failures; test_real_model.py::test_trained_cold_download_http_and_offline_reuse covers real provisioning/offline checks. Production model quality and native-runtime containment remain deployment responsibilities (local model guards); test_local_guard.py::test_no_gpu_never_downloads_non_onnx, test_local_guard.py::test_http_auth_complete_subject_and_content_not_retained, test_artifacts.py::test_dns_resolution_refuses_private_addresses. CUDA qualification now binds warmup placement to the actual worker PID and checks no replay under owned-worker faults; host selection and partial-result retention are synthetic-tested (test_onnx_gpu.py::test_qualification_harness_on_synthetic_cpu_graph, test_qualification.py::test_failed_fault_phase_preserves_completed_evidence). NVIDIA hardware and disruptive staging tests remain unverified; see qualification record. |
State handle hijacking (MUST, MUST NOT) |
Yes | Met; durable Tasks additionally bind every read/mutation to the existing owner, with indistinguishable unknown/inaccessible IDs; pending linked status reconciles against the verified journal; terminal receipts remain owner-bound; cancellation journals a distinct requester withdrawal first (task_wire_tests, unified_call_approvals_tests::approval_tasks) |
| Local MCP server compromise | Yes | Explicit native keychain stores, encrypted Linux Secret Service sessions and unavailable-store refusal (devtools-identity keychain tests); met for stdio; Tasks uses private storage and a lifetime lock, direct CLI cannot recover server tasks, and only pristine first-boot state is initialized (tasks::sqlite::tests, task_lifecycle_tests); MCP_AUTH_MODE=off over HTTP is a documented deviation |
| OAuth authorization URL validation | Yes (console) | Met |
| stdio transport in proxy scenarios | No — no proxy architecture | N/A |
| Mix-up attacks | Not today (single issuer) | Defended anyway (RFC 9207 iss) |
| Localhost redirect URI impersonation | No — console redirect is the public URL | N/A |
| CIMD trust policies | No — no client-ID metadata documents | N/A |
| Scope minimization | Yes | Met; served skills grant no permission and ship no allowed-tools (skills_wire_tests); schema 3 content rules narrow what an allowed write may carry (decoded write arguments inspected before dispatch; deny, hold with the findings digest in the hold key, or label; on_error deny by default; content_guard_tests); Jira binary downloads authorize the canonical GET and keep unclassified enforcing calls denied (jira_download_tests); Jira attachment uploads reuse canonical POST authorization, owner-bound artifact reads and bounded file preparation (jira_upload_tests, multipart_transport_tests) |
Qualifications appear beside the affected controls and in Deviations and residual risk.
Tool descriptions are compiled and pinned by the
tool-surface tests.
bitbucket_pr_update_guidance_uses_put additionally locks the corrected
Bitbucket PR-update guidance against accidentally restoring PATCH examples.
This strengthens MCP03 regression coverage; it does not change authorization
or validate the intent of an agent’s subsequent calls.
Routine Rust CI validates Linux and Windows on Reacher. Hosted macOS is
opt-in through repository variable MCP_CI_MACOS=true; leaving it unset
reduces platform regression coverage while account billing blocks hosted jobs.
Release builds still require both macOS architectures. This MCP04 validation
limit is recorded in the build threat model
and CI runner instructions.
Signing-key lifecycle
Section titled “Signing-key lifecycle”Policy/audit rotation uses the operator workflow. It re-signs only a verified baseline, preserves policy and revocation bytes, advances generations, and refuses drift or rollback to a compromised key. Owner-only staging holds the replacement private key; the recovery record binds the service environment file by digest and never copies it. Policy activation refuses while that key is still in the rotation directory, so the gateway’s configured paths never sit beside the key that signs them. Only redacted status and public references cross the browser boundary. The console handoff uses existing admin authorization and escaped templates, with no browser mutation or service-execution endpoint. Activation runs locally under the systemd service owner’s authority, uses no shell, and accepts only a loopback health origin. Journal history and old audit public keys are retained.
Tests: crates/control/tests/rotation.rs, configuration_admin_tests, and
unified_console_tests::rotation_console_is_an_authenticated_operator_handoff_without_private_keys.
A same-account systemd user service does not isolate the operator’s files from
that service identity; separate policy-signing custody requires deployment of
only the public signed artifacts. Multi-host orchestration and browser-triggered
activation remain outside this adapter. Local transition records do not replace
the gateway’s signed audit journal or end-to-end client authentication checks.
Confused deputy
Section titled “Confused deputy”N/A by architecture. The spec’s attack needs all four of: a static client ID against a third-party authorization server, dynamic client registration by MCP clients, a third-party consent cookie, and no per-client consent at the proxy.
This server is not an OAuth proxy. It does not register clients, does not mint
authorization codes, does not hold a static client ID on behalf of MCP
clients, and does not sit between an MCP client and a third-party
authorization server. Clients obtain their own tokens from the operator’s
identity provider and present them here; the RFC 9728 metadata document at
/.well-known/oauth-protected-resource names that provider
(src/server/auth.rs, protected_resource_metadata) and the flow ends there.
The console (crates/console/src/console/) is a first-party public client with its own
client ID, signing its own administrator in. It proxies nothing on behalf of
a third party, so there is no consent to be confused about.
If this changes, the whole “Required Protections” block of the spec —
per-client consent storage, a consent UI with CSRF protection and
frame-ancestors, __Host- consent cookies bound to client_id, exact-match
redirect_uri validation, and state stored only after consent approval —
becomes mandatory. It is not partially satisfied today; it is simply not
reached.
Client-credentials support is an optional advertisement through authenticated
HTTP discovery (MCP_CLIENT_CREDENTIALS_ENABLED), not a new OAuth proxy or
a grant restriction. Legacy initialization and ordinary client admission are
unchanged. oauth_tasks_compatibility_tests locks discovery/EMA compatibility;
admin_principal_kind_tests locks validation, scopes, revocation, policy and
machine-admin restrictions with the flag absent and enabled. The HTTP adapter
records its authentication gate explicitly; discovery reads live configuration
and disables the advertisement after an invalid reload. HTTP startup validates
the opt-in, while stdio and direct CLIs ignore it. Both EMA and client-credentials
opt-ins reject conflicting vendor settings. The
service-account setup
keeps secrets/assertions at the client–AS boundary and documents least privilege.
Token passthrough
Section titled “Token passthrough”MCP servers MUST NOT accept any tokens that were not explicitly issued for the MCP server.
Met. Two independent halves, both enforced.
Audience validation. The JWT validator pins the audience to the configured
resource identifier and the issuer to the configured issuer, accepting both
trailing-slash spellings because providers disagree about it
(crates/identity/src/auth/oidc.rs). exp, iss and aud are required spec claims,
nbf is validated when present, and clock skew is bounded by configuration.
The signing algorithm is pinned to RS256 on both the token header
(crates/identity/src/auth/oidc.rs) and the JWK itself, so neither alg: none nor an
HMAC-for-RSA key confusion is representable. A token minted for another
resource fails aud and never reaches a tool.
No passthrough. The stronger property is that there is nothing to pass
through. The inbound bearer is read from the header, handed to the validator,
and dropped. What survives is Principal (crates/contracts/src/policy/mod.rs: tenant,
subject, groups, scopes, authority) and TokenFacts
(crates/contracts/src/ports/token_validator.rs: iat, jti, validated actors) — claims,
never the credential. No vendor client can forward the caller’s token
because no vendor client can obtain it. Vendor credentials are resolved
independently from configuration or the OS keychain (crates/identity/src/auth/secrets.rs).
This is guiding constraint 4 in the enterprise product plan §0 (retired Enterprise repository), and it is enforced by shape rather than by discipline — the failure mode the spec warns about is unreachable rather than merely avoided.
The inference path keeps the shape. The provider routes (/v1/*,
runtime security plan §6) forward the client’s body under a credential that
is a server setting (MCP_INFERENCE_CREDENTIAL), resolved like a vendor
credential. The handler copies four request headers upstream
(content-type, accept, anthropic-version, anthropic-beta) and
nothing else, so the caller’s Authorization, x-api-key and cookie
never leave the gateway
(inference_role_tests::client_bearer_never_reaches_upstream). The routes
require the mcp:inference scope on the same validator as /mcp.
Server-side request forgery
Section titled “Server-side request forgery”Partially mitigated, with different controls at each outbound boundary. The MCP guidance discusses clients following OAuth discovery URLs. This server also consumes discovery documents, download locations, and clone URLs supplied by upstream services; each needs its own trust boundary.
Canonical API targets. CanonicalTarget accepts a path and query, joined
to the configured vendor base URL; absolute URLs and user information cannot
be supplied through that target. This prevents host substitution on that API
path, but does not cover every outbound operation. See
crates/contracts/src/policy/canonical.rs and its canonicalization tests.
Control-plane URLs. OIDC issuer, discovery endpoints and JWKS use
require_https with PlainHttp::Loopback (crates/identity/src/auth/oidc.rs).
The public URL, admin client, Vault and SIEM enforce their own scheme checks.
Discovery must identify the configured issuer, advertised endpoints are
validated before use, and document bodies are bounded. These clients do not
follow redirects. Cloud metadata secret adapters have a separate, intentional
link-local boundary; that exception does not apply to OIDC URLs.
Downloads and redirects. The shared HTTP adapter disables automatic
redirects (crates/http/src/lib.rs). Explicit download handling may follow a
bounded redirect chain. Cross-origin destinations require
MCP_DOWNLOAD_ALLOWED_ORIGINS; API credentials are stripped when crossing
origins, and redirect origin and path are authorized before dispatch. This
is not a claim that redirects never occur. The regression evidence is
tests/integration_download_tests.rs, including
github_log_redirect_strips_credentials_and_returns_artifact and
redirects_are_authorized_by_origin_and_path_before_dispatch.
Browser automation. The six playwright_* tools (opt-in playwright
feature) are stdio by default. HTTP sessions omit them and refuse direct
calls before any launch unless the operator sets PLAYWRIGHT_HTTP_ENABLED
and declares the browser’s network containment (PLAYWRIGHT_CONTAINMENT:
an allowlist forward proxy the browser is pointed at with Chromium’s loopback
bypass removed and WebRTC held to proxied transports, so only Chromium-based
engines are accepted; a pod network policy alone is refused, since it cannot
block the pod’s own loopback); the opt-in refuses to turn on otherwise. Locked by
http_transport_tests::http_refuses_playwright_tools_before_launching_anything,
playwright_http_tests::the_opt_in_refuses_to_turn_on_without_containment,
tool_surface_parity_tests::stdio_advertises_playwright_tools_when_selected,
and proven outside the process by the playwright-containment CI job
(scripts/ci/playwright-containment-smoke.sh: link-local, loopback, RFC 1918
and the metadata address refused directly, via redirect, via WebSocket and
as subresources). Each page is bound to the bearer subject the transport
validated, on every call (playwright_http_tests::handles_are_bound_to_the_bearer_subject_without_a_policy_scope),
closed on that subject’s revocation, capped per owner and per process before
launch under concurrent load with a flat gateway resident set
(::opens_past_the_cap_are_refused_before_spawn_with_flat_memory), closed
when idle on the handle’s own clock, limited by a per-child cgroup memory
ceiling where Linux lets the gateway write one, and driven through a line
client that refuses oversized child output before parsing it
(src/vendor/playwright/rpc.rs, playwright_bridge_tests). A browser the
kernel kills is a dropped handle. Under a policy file, clicks and fills are
act_on_page writes a require_approval rule holds at admission
(::click_and_fill_are_held_for_approval_when_policy_requires). Page text
is bounded and scrubbed before the model sees it
(devtools_contracts::browser::snapshot, fuzzed). The deployment shape is
docs/playwright-http-deployment.md; the gateway runs as one replica.
Git cloning. The MCP bb_clone tool is available over stdio only. HTTP
sessions omit it from discovery and reject direct calls before admission,
filesystem writes or subprocess execution, including loopback HTTP and both
stateless and initialized sessions. Direct CLI cloning remains a local
operator command. Locked by
http_transport_tests::modern_tools_list_is_stateless_deterministic_and_cacheable,
::http_refuses_bb_clone_before_creating_a_destination and
tool_surface_parity_tests::stdio_advertises_bb_clone_when_bitbucket_is_enabled.
bb_clone takes its clone URL from the repository metadata
Bitbucket returns, so that URL is vendor data and is bound to the request
before Git sees it (src/controllers/clone.rs). The href must be one of the
three shapes Bitbucket emits (https://host/ws/slug.git,
ssh://git@host/ws/slug.git, git@host:ws/slug.git); its host must be the
Bitbucket host the vendor base URL implies (bitbucket.org in production);
and its path must name exactly the workspace and repository the call asked
for. Userinfo, queries, fragments, extra path, and any host or segment that
begins with - are refused as malformed, and a refused href is never echoed.
Git then runs without a shell, with -- before the URL, with its transport
pinned to SSH and HTTPS (protocol.allow=never plus explicit allows, so
ext::, file:// and git:// cannot be used even by a URL that passed), with
HTTP redirects refused (http.followRedirects=false; Git’s default follows
one on its first request, which would let the upstream move the clone off the
bound host), with hooks pointed at a non-existent directory, and in a cleared environment that
keeps only what a clone needs: PATH, HOME, the SSH agent socket, an
operator-set GIT_SSH_COMMAND, trust-root and proxy variables, locale and
temp space. GIT_CONFIG_PARAMETERS, GIT_SSH, GIT_PROXY_COMMAND,
GIT_DIR and the rest of git’s environment-driven configuration do not
survive, and GIT_CONFIG_NOSYSTEM=1 drops the system config file. The user’s
own ~/.gitconfig and ~/.ssh are deliberately honoured: that is where
credential helpers and keys live.
What this does not do: Git’s network traffic is Git’s, not the Rust HTTP
adapter’s, so it passes no egress chokepoint and produces no egress record
(the metadata fetch that produced the URL does). The call’s outcome record
lists it under subprocesses with the URL Git was handed and how Git ended,
so the journal shows where the clone went
(cli_audited_tests::bb_clone_journals_the_git_destination_on_its_outcome). BITBUCKET_CLONE_ROOT confines the target
directory when configured. Process-level containment (seccomp, network
namespaces) is a deployment control. Locked by clone_tests:
a_clone_url_on_a_foreign_host_is_refused_before_git_runs,
a_clone_url_for_a_different_workspace_or_slug_is_refused,
malformed_and_option_shaped_clone_urls_are_refused,
git_runs_with_a_pinned_transport_and_a_cleared_environment, and
git_refuses_a_redirect_on_its_first_request (the real git binary against
a redirecting origin); the adapter’s
environment handling by
shell_tests::system_command_runner_applies_an_allowlisted_environment. The
grammar itself (devtools_contracts::clone_url) is fuzzed by
fuzz/fuzz_targets/clone_url.rs, and clone_url_invariants_tests runs the
same invariants over a seeded corpus on stable: an accepted href names
exactly the requested repository on the expected host and nothing else.
Network restrictions. The application does not provide universal DNS/IP range filtering. Operators must constrain destinations at the network boundary; see deviation 2.
State handle hijacking
Section titled “State handle hijacking”MCP servers that implement authorization MUST verify all inbound requests. MCP servers MUST NOT treat possession of a state handle as authentication.
Met. Every handle this server mints is bound server-side to the principal that created it, and a handle presented by anyone else is indistinguishable from one that does not exist.
MCP session IDs. enforce_session_owner (src/server/session.rs) runs
inside the bearer middleware, so the validated Principal is already in the
request extensions. A request naming a session another principal owns — or one
with no binding — gets the same 404 rmcp gives an unknown session
(session_not_found, src/server/session.rs), so a session ID is not a
capability and probing does not distinguish “not yours” from “not there”. A
request with no session ID passes through; if its response creates a session,
the session is bound to the requester before the response leaves.
Artifact IDs. Identical treatment: only the principal that created an
artifact may download it or read it with artifact_read, and a cross-owner
request is the same 404 (or not-found tool result) as an unknown ID
(download_artifact, src/server/http.rs). Ownership is taken from the
validated principal on every admitted call, with or without an audit
journal: a journal-less call runs under an owner-only task-local
(with_acting_owner, crates/policy/src/policy/scope.rs) rather than the
enforcing call scope, so bb_upload reads only the caller’s artifacts and
the response cache is partitioned the same way
(principal_partitioning_tests::without_a_journal_a_tool_created_artifact_belongs_to_its_creator,
::without_a_journal_upload_reads_only_the_callers_artifacts,
::without_a_journal_the_response_cache_is_partitioned_by_principal).
Without inbound auth the requester is local, which is also what every
locally created artifact is owned by, so community mode is unchanged
(::stdio_calls_stay_local).
Console session and login IDs. 256 bits from a CSPRNG, base64url
(random_id, crates/console/src/console/session.rs) — the spec’s “secure,
non-deterministic handles … avoid predictable or sequential identifiers”.
Both maps are bounded and expiring; a login in flight is consumed exactly once
(Bounded::take removes before checking expiry, crates/console/src/console/session.rs)
and lives at most five minutes, which is inside the spec’s suggested ten.
Revocation. When the revocation list changes, sessions belonging to newly
revoked subjects are closed and the validated-token cache is cleared
(watch_revocations, src/server/http.rs), so nothing derived from a revoked
token outlives it. See docs/revocation-runbook.md.
Local MCP server compromise
Section titled “Local MCP server compromise”The spec’s guidance for servers intended to run locally: use stdio to limit
access to just the MCP client, or if using an HTTP transport, require an
authorization token or use a Unix domain socket.
stdio: met. src/server/stdio.rs is the default local transport and is
reachable only by the process that spawned it.
HTTP with MCP_AUTH_MODE=oidc: met. Every tool call needs a validated
bearer.
HTTP with MCP_AUTH_MODE=off: a documented deviation. See
Deviations.
DNS rebinding — listed by the spec as the third local-server attack — is
blocked for browsers by an Origin allowlist applied globally to the
transport’s routes (origin_allowlist, src/server/http.rs;
OriginAllowlist, src/server/origin.rs). It admits loopback origins, the
origin of MCP_PUBLIC_URL, and the exact origins the operator lists in
MCP_ALLOWED_ORIGINS (each https, or http on loopback; a malformed entry
refuses startup). An attacker page at evil.example that resolves to
127.0.0.1 still sends Origin: https://evil.example and is refused with
403. A missing Origin is allowed, because non-browser MCP clients do not
send one and the header is exactly what distinguishes them; browsers send it on
every cross-origin fetch and form post, which is the case that matters. Until
the 2026-09-15 assessment (S11) the list was loopback-only in every auth mode,
which erred safe but refused browser-hosted clients behind a public URL.
A fail-closed startup gate refuses to boot with a non-loopback bind while
no inbound authentication exists (validate_startup_security,
src/server/http.rs). The loopback bind is the community trust
boundary; removing it without authentication would expose every configured
vendor credential to the network, so that combination is not a warning, it is a
refusal.
OAuth authorization URL validation
Section titled “OAuth authorization URL validation”Met. The spec requires clients to allow only http/https (loopback-only
for http), reject javascript:, data:, file:, vbscript:, prefer
allowlists over blocklists, and never open URLs through a shell.
The console is the only component that sends a browser to a provider-supplied
URL. require_https with PlainHttp::Loopback (crates/identity/src/auth/oidc.rs) is
allowlist-based — it admits https, and http only on loopback, rejecting
everything else — and it is applied to authorization_endpoint and
token_endpoint at discovery time (crates/identity/src/auth/oidc.rs), before either is
used. A javascript: or data: endpoint therefore cannot reach a Location
header. No shell is used to open a URL anywhere in this codebase.
Content Security Policy. The console serves
default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self'; connect-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'
with no inline script or style, plus Referrer-Policy: same-origin,
X-Content-Type-Options: nosniff, X-Frame-Options: DENY, and no-store on
everything but static assets (CSP and security_headers, crates/console/src/console/mod.rs).
This is stricter than the spec’s script-src 'self' suggestion and covers the
clickjacking requirement the confused-deputy section states for consent pages.
Cookies. HttpOnly; Secure; SameSite=...; Path=...; Max-Age=... on both
console cookies (set_cookie, crates/console/src/console/mod.rs). The session cookie is
SameSite=Strict and scoped to /console; the login cookie is SameSite=Lax
— it must survive the provider’s cross-site redirect back to the callback, which
Strict would drop — and is scoped to the callback path alone, for five
minutes. The __Host- prefix is not used: it requires Path=/, and path
scoping is the more valuable of the two properties here. The spec mandates
__Host- for consent cookies in an OAuth proxy, which we do not have.
Policy proposal review. Pending administrative candidate reads retain admin
scope and the proposer’s tenant boundary. The API returns the exact stored
signed generation only after its digest check; the console escapes all policy,
manifest and revocation text. Downloads use fixed attachment filenames,
application/octet-stream, nosniff and Cache-Control: no-store. Reads do not
approve or apply anything; decision forms retain the origin checks below.
Locked by unified_console_tests::proposal_review_shows_stored_policy_and_downloads_exact_signed_bytes
and unified_approvals_tests::pending_proposals_survive_a_restart_and_a_tampered_journal_is_refused.
This exposes signed operator documents to same-tenant administrators; it is
not a redaction mechanism and does not protect files after an administrator
explicitly saves them.
CSRF. Every non-GET console request must carry the console’s own Origin,
or Sec-Fetch-Site: same-origin when no Origin is present (same_origin,
crates/console/src/console/mod.rs). Locked by
tests/unified_console_tests.rs::non_get_requests_need_the_public_origin, which
exercises nine refused origin/fetch-site combinations and three accepted ones.
PKCE and state. Authorization-code flow with PKCE S256
(crates/console/src/console/login.rs); the console is a public client and holds no client
secret. A 256-bit state is stored server-side, keyed by the login cookie, and
compared on callback; a mismatch, an empty value, or a second use of the same
login is refused (callback_handler, crates/console/src/console/mod.rs).
stdio transport in proxy scenarios
Section titled “stdio transport in proxy scenarios”N/A. The spec is explicit that this “only applies to MCP implementations
that use a proxy architecture, not to direct stdio transport usage”. This
server is not a proxy: it never spawns MCP servers as child processes, and
there is no local proxy authentication token to steal. This does not imply
process isolation: bb_clone spawns Git, whose
separate boundary is described in Server-side request forgery.
Mix-up attacks
Section titled “Mix-up attacks”Not applicable today; defended anyway. The attack needs a client talking to
several authorization servers, one of them hostile. The console has exactly one
configured issuer, and the JWT validator pins iss to it
(crates/identity/src/auth/oidc.rs), so an authorization response from a different
provider cannot yield a usable token here.
The spec’s mitigation is RFC 9207 authorization-response validation: bind the
response to the authorization server recorded before the redirect. The console
validates the iss parameter when the provider returns one, comparing it to
the configured issuer and refusing a mismatch (callback_handler,
crates/console/src/console/mod.rs). An absent iss is accepted, because the spec notes the
mitigation “depends on honest authorization servers emitting iss” and not all
providers do. The check costs nothing today and stops the configuration from
silently becoming unsafe if a second issuer is ever added.
Localhost redirect URI impersonation
Section titled “Localhost redirect URI impersonation”N/A. The console’s redirect_uri is derived from MCP_PUBLIC_URL, which
must be https (or http on loopback for development) and is validated at
startup (src/server/auth.rs). It is a registered server URL, not a
localhost port any local process could claim. We also do not use Client ID
Metadata Documents, which is the precondition for the spec’s attack.
CIMD trust policies
Section titled “CIMD trust policies”N/A. This server does not act as an authorization server and does not accept URL-based client IDs, so there is no client-metadata document to fetch and no trust policy to apply.
Scope minimization
Section titled “Scope minimization”Met.
- Two scopes, no wildcards.
mcp:toolsfor the data plane (MCP_REQUIRED_SCOPE, default insrc/server/auth.rs) andmcp:adminfor the control plane, which is a separate boundary with its own rate limiter (InboundAuth::for_admin). No*,all, orfull-accessscope exists — the spec’s first “common mistake”. - Scope is not the only gate on the control plane.
mcp:adminadmits a kind of principal as well as a scope: a token the provider positively marks as a machine’s (per profile, on validated claims — never on subject naming) is refused at/admin/*under the defaultMCP_ADMIN_PRINCIPALS, because the two-person approval rule compares subjects and two service accounts would satisfy it. Locked bytests/admin_principal_kind_tests.rs; CF-42 carries the per-tenant evidence that a real provider emits the marker. - The catalog is not published.
scopes_supportedin the RFC 9728 document carries only the required scope, not every scope the server knows (src/server/auth.rs) — the spec’s second “common mistake”. Locked bytests/inbound_auth_tests.rs. - Precise challenges. A token that validates but lacks the scope gets
403withWWW-Authenticate: ... error="insufficient_scope", scope="<required>"naming exactly the scope needed (insufficient_scope,src/server/auth.rs) — the spec’s “emit precise scope challenges; avoid returning the full catalog”. - Scopes are not the authorization decision. The spec’s last common mistake
is “treating claimed scopes in token as sufficient without server-side
authorization logic”. A valid, correctly scoped token only gets a request as
far as the policy engine, which decides per action on the canonical outbound
request and journals the decision (
crates/policy/src/policy/, plan §1.3). Scope is admission; policy is authorization.
Deviations and residual risk
Section titled “Deviations and residual risk”Four transport and identity qualifications follow. The Git boundary is covered above; prompt injection, downstream sharing and shadow servers are covered in the OWASP assessment.
1. MCP_AUTH_MODE=off over loopback HTTP has no inbound token
Section titled “1. MCP_AUTH_MODE=off over loopback HTTP has no inbound token”Spec: local servers using an HTTP transport SHOULD require an authorization token or use a Unix domain socket.
What we do: with MCP_AUTH_MODE=off there is no inbound authentication. The
loopback bind is the trust boundary, and startup refuses any non-loopback bind
in this mode (validate_startup_security).
Residual risk: any process running as any user on that host can call every
enabled tool within the configured local policy and vendor credential authority.
Without a policy, that authority is the local credential set. The Origin allowlist
stops a browser, not a local process.
Mitigation available to operators: use the stdio transport for local use —
it is the default and it is reachable only by the client that spawned it — or
run with MCP_AUTH_MODE=oidc. This is a deliberate posture (plan §1.2), not an
oversight, but it is weaker than the spec’s SHOULD and should be stated as
such in any assessment. Whether to close it — a static local token, or a Unix
domain socket — is tracked as CF-44.
2. No private-IP range blocking on control-plane fetches
Section titled “2. No private-IP range blocking on control-plane fetches”Spec: clients SHOULD block requests to private and reserved ranges
(10/8, 172.16/12, 192.168/16, 127/8, 169.254/16, fc00::/7,
fe80::/10), while also warning “avoid implementing IP validation manually”
because of encoding tricks, and recommending egress proxies.
What we do: https-or-loopback only. We do not resolve and range-check.
Residual risk: these control-plane URLs come from operator configuration
or discovery documents served by the configured issuer; they are not canonical
tool targets. This does not cover download or Git destinations described above. One case is a compromised or misconfigured identity
provider naming, say, https://169.254.169.254/... as its jwks_uri. Redirect
following is already off, which removes the redirect-chain variant, and the
TOCTOU DNS-rebinding variant the spec describes would additionally need the
attacker to control DNS for a name with a valid certificate.
Why not fixed in code: the spec itself advises against hand-rolled IP validation, and a correct implementation must pin DNS between check and use. An egress proxy enforcing network policy — the spec’s own recommendation — is the right control, and it belongs in the deployment, not in this binary.
Outbound posture: restrict egress from the gateway and control pods to
the identity provider, the SIEM, the configured vendor hosts, and the cluster
resolver; block the instance metadata endpoint (169.254.169.254) and, where
nothing the pods need lives there, the private ranges. The Helm chart ships
an optional policy enabled by setting networkPolicy.enabled: true; the
shipped default is false (deploy/helm/mcp-devtools, one
NetworkPolicy per role: ingress from the WAF pods (or, with the WAF
disabled, the ingress controller) on the HTTP port only, egress to DNS plus
HTTPS anywhere except link-local by default, narrowed by
networkPolicy.egress.allow / except; and one for the WAF, which may open
only DNS and the roles’ HTTP port). It is opt-in because a CNI that does not
enforce NetworkPolicy accepts the object and ignores it. The cloud
templates enforce the same posture with the platform’s own objects, on by
default: deploy/aws/fargate.yaml gives the task a security group whose
egress is TCP 443, NFS to the file system and the SIEM port, with no
all-traffic rule (a Fargate task has no instance metadata endpoint to
block); deploy/azure/main.bicep gives the environment subnet a network
security group that denies 169.254.169.254/32 before any allow and denies
everything not listed. operations_tests::aws_fargate_template_preserves_the_deployment_constraints
and ::azure_container_apps_template_preserves_the_deployment_constraints
lock both.
Inbound edge: every shipped deployment path puts a WAF in front of the
server by default (ModSecurity with the OWASP CRS, blocking, rules in
deploy/waf/). It is defense in depth for the console, the admin API and
the MCP envelope, not a substitute for any control on this page: tool-call
arguments and authored policy content are exempt from its injection rules,
and it makes no authorization decision. Keep server, ingress, nginx and
inspection caps aligned: Helm validates waf.clientMaxBodySize against
waf.requestBodyLimitBytes; Compose/k8s use deploy/waf/body-limit.conf.
Kubernetes resolves fixed Service destinations using pod DNS at request time,
so recreation needs no restart; client headers cannot choose an upstream.
Keep demo credentials outside the checkout; the exact local token filename
is also ignored as a staging backstop. These controls are locked by the
operations tests and WAF body/DNS smoke scripts. See the
OWASP assessment.
Network egress restrictions are distinct from per-request authorization in
crates/policy/src/policy/egress.rs; both are needed for shared deployments. CF-43 records
the history.
3. openid is requested but no ID token is validated
Section titled “3. openid is requested but no ID token is validated”What we do: the console requests openid mcp:admin by default and reads
only the access_token from the exchange (crates/console/src/console/login.rs). It does
not request or validate an ID token; the administrator’s identity comes from
the access token, which the admin API validates at its own boundary.
Residual risk: none identified. Security comes from PKCE, state, and the
audience-validated bearer. openid remains in the default scopes because
several providers expect it on an OIDC authorization request and operators have
configured against it.
Previously: a nonce was generated and sent but never verified, because
verifying it requires an ID token nobody validated. That parameter has been
removed rather than left as a check that appears to exist and does not.
4. Console routes sit outside the transport’s Origin allowlist
Section titled “4. Console routes sit outside the transport’s Origin allowlist”What we do: the console router is merged after .layer(origin_allowlist)
and .layer(cors) in build_app_inner (src/server/http.rs), so neither
applies to it. This is deliberate: the console is served at the deployment’s
real public origin and carries its own origin check, and it predates the
allowlist admitting the MCP_PUBLIC_URL origin. Moving it inside the
allowlist would now be possible; it has not been done, so the test below still
locks the layer ordering.
Compensating controls: the console carries its own origin check on every
non-GET request (same_origin), SameSite cookies, and a CSP with
form-action 'self' and frame-ancestors 'none'.
Why it is called out: the correctness of this depends on axum’s layer
ordering — .layer() applies to routes registered before it — which a refactor
could silently invert. It is locked by
tests/unified_console_tests.rs::non_get_requests_need_the_public_origin: that test
runs through the composed router and checks the console origin rules. The
transport also admits the configured public origin, so acceptance of that
origin alone does not prove middleware ordering. A separate assertion checks that the console’s 403 does not
leak into the server’s fallback for unknown paths.
Keeping this document true
Section titled “Keeping this document true”Re-read this page against the spec whenever:
- the MCP specification revision this server targets changes (currently
2026-07-28, seewith_stateless_protocol_metadata_requiredinsrc/server/http.rs); - a new inbound authentication mode, issuer, or client is added — in particular, anything that would make this server an OAuth proxy, which activates the entire confused-deputy section;
- a tool gains a parameter that reaches an outbound URL, which would end the
structural SSRF argument in Server-side request forgery;
bb_cloneis the standing exception, and its URL is bound to the request by validation rather than by shape, so a change to that validation needs the same review; - a new handle type is returned to callers, which needs the owner binding described in State handle hijacking.
Authentication troubleshooting diagnostics
Section titled “Authentication troubleshooting diagnostics”Inbound HTTP diagnostics (src/server/diagnostics.rs) record arrival and
response-header completion, including public discovery and failed auth, with
a server-generated ID also returned as x-request-id. Authentication warnings
share that ID. Fields are restricted to standard methods, route templates,
bearer presence, status and duration; queries, literal path parameters,
cookies, token values, bodies and claims are excluded. Unknown paths and
custom methods use fixed markers, and caller correlation headers are ignored.
inbound_auth_tests::http_diagnostics_correlate_discovery_and_refusals_without_request_secrets
locks correlation, discovery/refusal coverage and exclusion without relying
on the log redactor. These retained, filtered diagnostics supplement the
durable journal; they cannot prove client or IdP steps outside this server.
PostgreSQL connection failures for NinjaOne and WRDS produce warnings with the
resolved host, port, database, TLS mode and transport cause chain in the process
log (~/.mcp/data/mcp-server-devtools.<session>.log). TLS setup failures also
warn. Connection configuration, SQL and credential fields are not logged;
client-side causes mask the selected username/password. Server ErrorResponse
messages, details and hints are omitted in favour of SQLSTATE. Host and database
names remain visible to operators. postgres_connection_tests locks these
MCP01/MCP08 controls using loopback TCP and PostgreSQL protocol fixtures. Tool
error envelopes and audit payloads remain unchanged. Existing RUST_LOG filters
apply; no debug setting is required for these warnings.
NinjaOne database queries can return a plan instead of rows
(explain on ninjaone_db_query_central, _division and _named). This is
an MCP05 surface, so the control is structural: the caller’s sql still passes
the unchanged single-SELECT guard, and the server renders the EXPLAIN
prefix from four typed fields (analyze, buffers, verbose, format) in a
closed, deny_unknown_fields schema, so caller text never becomes an option
and EXPLAIN of INSERT, UPDATE, DELETE or a data-modifying CTE is
refused before any connection. analyze executes the SELECT once, so it runs
in the same read-only session and transaction under the 10-second statement
timeout; the server refuses a writer function or write statement under it
(checked against PostgreSQL 17: cannot execute INSERT in a read-only transaction, zero rows written). rowLimit does not bound a plan, so the
statement timeout is the only cap on analyze; plan text can include literals
from the query the caller supplied, which the audit digests already treat as
argument content. explain_is_still_guarded_before_connecting and
explain_statement_renders_only_typed_flags (library tests),
explain_arguments_refuse_unknown_options and
central_explain_reaches_the_port_as_typed_flags_without_a_row_cap
(application_boundary_tests) lock it. Policy classifies the three tools, not
the explain field, so a rule that allows a query tool allows its plan
(MCP02: no new authority, since analyze runs a query the rule already
permits).
Container builds for amd64 and arm64 share Cargo artifacts, but certify source
fingerprints separately for each target. Keep the Dockerfile’s --cache-scope
argument when changing the build pipeline: a successful build for one target
must not certify cached output for the other. tests/build_cache_test.py locks
this against source changes with old timestamps and against failed builds.
Nested credentials and acknowledged audit forwarding
Section titled “Nested credentials and acknowledged audit forwarding”Legacy NinjaOne server-entry passwords and TOTP seeds use the same
startup/refresh secret snapshot as top-level credentials. Calls read that
snapshot without fetching a provider; unresolved references refuse login
before vendor I/O. Destination URLs, prefixes and commands remain literal
administrator configuration. The discovery boundary is locked by
config_tests::ninjaone_nested_reference_discovery_is_limited_to_credential_fields;
login, rotation, TOTP and unresolved-reference behavior are locked in
ninjaone_session_tests (OWASP MCP01/MCP05).
For an acknowledged audit copy, select relp+tls:// and configure the receiver
to persist its queue before replying. The adapter validates the TLS peer and
optional client certificate, negotiates syslog, and requires a matching 200
response for every transaction before advancing a batch cursor. Missing,
malformed or rejected responses discard the connection and retain the cursor.
Responses are bounded and peer prose is never logged. An acknowledged prefix
can be duplicated on retry; deduplicate by origin and sequence. TCP syslog
retains its transport-only acknowledgement gap. audit_forwarding_tests RELP
conformance/cursor tests and relp_invariants_tests lock these controls
(OWASP MCP08). See the forwarding runbook.
RELP oversized-record notices preserve forwarding progress without silently
skipping evidence: the SIEM receives the original sequence, JSON byte count
and SHA-256 digest, and the full line remains in the journal. Alert on
audit_forwarding.reason = relp_message_too_large, retrieve by origin/sequence
and verify the digest. Every notice requires acknowledgement before cursor
advance. relp_oversized_notice_is_acknowledged_before_cursor_progress locks
this MCP08 control; relp_batch_can_outlast_each_exchange_budget and
relp_reconnects_an_idle_closed_session_in_the_next_delivery lock exchange
budgets and idle connection recovery. Shared TLS receiver URL validation
rejects user-info, paths, queries and fragments without echoing URL contents
(MCP01/MCP05), locked by the configuration rejection tests.
Local Vault import administration
Section titled “Local Vault import administration”The local import helper is an operator workflow outside MCP dispatch. Explicit
--admin-token-file or --admin-recovery-file authorizes bootstrap using that
credential; it is never inferred from the runtime config. A unique temporary
policy grants only create/update on planned KV v2 data paths, and a separate
in-memory token performs the CAS-zero imports. It is non-renewable with a
ten-minute maximum lifetime; both revocation and policy deletion are attempted
after success or failure. TLS/origin checks, redirect refusal and sanitized
provider errors apply to all administration calls. The runtime token is unchanged.
Regression evidence is in scripts/operations/test_import_configs_vault.py:
test_operator_import_over_http,
test_operator_mode_scopes_writer_and_cleans_up_without_logging_tokens,
test_operator_preview_never_opens_admin_credentials_or_contacts_vault, and
test_operator_failure_revokes_writer_and_deletes_policy. The administrator
credential is trusted local input. Interruptions and cleanup failures may leave
a policy behind; the token TTL bounds access and warnings identify cleanup
needed. This workflow uses Vault auditing when configured, not the MCP journal.
Durable task handles
Section titled “Durable task handles”Tasks reuse the validated issuer/tenant/subject owner and HTTP middleware on
every operation. Capability metadata only selects protocol compatibility;
legacy initialize cannot enable creation. The SQLite schema has no token,
vendor credential or original-argument fields. Input updates are discarded,
not interpreted as administrative approval. Results are bounded to 16 KiB.
Approval results are minimized receipts; background results are the tool’s own
output, held at rest under the owner binding, with larger results moved to the
owner-bound artifact store. Tests in task_wire_tests,
task_lifecycle_tests and background_task_tests lock these boundaries.
Background execution is limited to a fixed allow-list of long-running,
non-mutating or idempotent tools (tools::tasks::BACKGROUND_TOOLS). It starts
only after full admission and a committed task, never turns a refusal or
approval hold into work, and is bounded by MCP_TASKS_MAX_RUNNING with a
synchronous fallback (background_task_tests).
Persistence uses verified WAL/FULL, one owning process and a bounded worker queue. Corruption or uncertain writes refuse service; a backup restore cannot create authority or undo approval consumption. Private local storage, reliable sync and platform ACLs remain operator obligations. There is no general prompt-injection filtering or multi-replica support. See tasks deployment for recovery and evidence limits.
PR 4 connects held calls only after explicit request negotiation. Proposal/task
association commits before a handle is returned. The verified approval journal
controls pending-task reconciliation, including after restart. Terminal results
are retained historical receipts, bound to the authenticated owner and containing
the proposal ID and execution deadline; they remain readable when approvals are
disabled. They are not independent audit evidence. Updates cannot decide;
requester withdrawal uses call_cancellation, never a free-text rejection reason.
Hold indexes include issuer ownership. Capacity/availability failures preserve the
ordinary approval response without executing work. Fresh admission and single
consumption still govern execution, even if retained history is stale.
unified_call_approvals_tests::approval_tasks covers races/partial commits and
unified_jira_create_approval_tests::approval_tasks covers stale tokens,
revocation and changed upstream identity; tasks::sqlite::tests covers v1-to-v2
migration and retained associations. This extends the state-handle and approval
controls above without introducing saved execution context.
Tasks availability controls additionally isolate direct CLI bootstrap from the
store (direct_cli_neither_locks_nor_recovers_the_server_task_store). Admitted
requests await durable replies; queue delay never disables Tasks
(slow_reads_and_writes_do_not_poison_the_store). SQLite FULL refuses capacity
while preserving reads/cleanup, expired rows do not consume admission capacity,
and lowered limits preserve startup (tasks::sqlite::tests). Empty first-boot
files resume only after pristine-schema validation under the lifetime lock;
nonempty unversioned databases remain refused. Legacy Tasks methods return
method-not-found, and supported later protocol versions pass the version gate.
Jira binary attachment downloads
Section titled “Jira binary attachment downloads”jira_download_attachment reuses the bounded streaming and owner-bound artifact
controls: numeric attachment IDs, generated paths, byte/disk limits, canonical
egress authorization, explicit redirect allowlisting and credential stripping.
It returns metadata without archive previews and never extracts or executes
attachments. Clients retrieve bytes through owner-checked artifact_read or
the authenticated HTTP artifact endpoint. jira_download_tests covers these
tool-specific boundaries; background_task_tests covers negotiated task and
synchronous retrieval. The built-in extractor leaves this native tool
unclassified, preserving denial under enforcing file policies.
Native credential storage
Section titled “Native credential storage”The identity adapter uses keyring-core with explicit macOS Keychain, Windows
Credential Manager and Linux dbus Secret Service stores. It does not install or
consume a mutable global default store and cannot select a mock as a fallback.
The Linux store negotiates encrypted DH sessions; errors disclose fixed
categories, excluding daemon payloads and secret bytes. Current/legacy lookup
and removal preserve saved credentials. These MCP01 controls are locked by
auth::keychain::backend::tests, crates/identity/tests/keychain_tests.rs, and
the isolated old-to-new compatibility runner scripts/ci/keychain-compatibility.sh.
For MCP04, the deprecated zbus chain is absent from the product lockfile and the advisory gate has no exceptions. Vendored libdbus adds C/FFI to desktop Linux; its embedded revision needs monitoring beyond RustSec and binary rebuilds for updates. Native stores still depend on OS access controls and an available, unlocked service. See backend and packaging evidence.
Tenant-bound governance reports
Section titled “Tenant-bound governance reports”The runtime security plan’s data map (PR 5) answers which principals of
one tenant touched which classes through which vendors, tools and models.
It is served three ways, and each is bound to one tenant: the admin API’s
reports/data-map takes a window and nothing else, binding the tenant to
the bearer’s verified identity, and the generic usage route refuses a
data_map query so no body can name a tenant; the console page sends the
window and renders what the boundary answers; audit report --data-map
opens the rollup file and takes --tenant explicitly, as an auditor with
the file is outside the boundary. The rows it summarises are class ids
with where they went, never content, and they are telemetry: the journal’s
marked, labels and owner_classes export columns are the record.
admin_api_tests::data_map_is_bound_to_the_callers_tenant and
unified_console_tests::data_map_is_tenant_scoped lock the binding;
operations_tests::compose_front_end_never_forwards_metrics locks that
the overlay’s unauthenticated metrics page stays on the compose network.
Content guard on write arguments
Section titled “Content guard on write arguments”Runtime security plan PR 1. A schema 3 policy bundle can carry content
rules over the decoded arguments of non-read tool calls. The application
asks the ContentGuard port after admission and before the approval gate
and the intent; the built-in detectors are the logger’s credential
patterns, shared through devtools_contracts::guard::secrets, fuzzed
(fuzz/fuzz_targets/secret_detector.rs) and bounded by
MCP_GUARD_MAX_SUBJECT_BYTES. The best-practice mapping:
-
Least privilege beyond authorization (MCP02). A write the policy allows is still refused, or held for a second person, when its arguments carry a credential. The hold key binds the findings digest, so an approval covers exactly the content that was reviewed (
content_guard_tests,hold_key_tests::content_findings_bind_the_key_and_their_absence_keeps_the_v1_key). -
Fail closed (decision 4). A guard that cannot inspect follows the rule’s
on_error,denyunless the rule sayslabel; a rule this build cannot enforce is a load error, not a silent no-op. -
Decode before matching (decision 15). JSON escapes are resolved by the parser before inspection; object keys are inspected too (
content_guard_tests::json_escaped_secret_in_write_argument_is_denied,secret_detector_invariants_tests). -
Evidence without content (MCP08, MCP10).
content_findingrecords name rule, detector, class, count and action; refusals name the rule and detector; neither carries the value, and the caller-facing decision omits the rule list. -
What an owner has read follows the owner (PR 4, decision 7). Flow rules deny a vendor call or a prompt once the verified owner’s taint classes satisfy
when.owner_has; the marks are keyed by issuer, tenant and subject, never by session, token or run id, expire by TTL, and are read before admission on both paths in the one gateway process (flow_policy_tests,policy_engine_tests::flow_rule_denies_prompt_after_prod_read). -
Budgets by reservation, not by hindsight (PR 4, decision 17). A request’s estimate is reserved under one lock before anything is forwarded, all or nothing across the applicable budgets, so concurrent requests cannot each pass a nearly exhausted limit; a request that cannot report usage follows each budget’s
unknown_usage, deny by default; an exceeded budget denies or holds for a second person (run_budget_tests). -
A held prompt is the same bytes or nothing (PR 4). The inference hold key digests the exact request body and the findings, and the same gate, proposal records and admin approval as a tool call decide it (
unified_call_approvals_tests::inference_hold_round_trip). -
One kill switch for both paths (PR 4). The signed revocation list refuses a subject’s prompts and tool calls alike and leaves its taint in place (
revocation_tests::revoked_subject_is_refused_on_both_paths_and_keeps_its_taint). -
Tested as an attacker chains it (governance plan WP6). Five multi-step workflows run against one deployment: prod logs copied out from a second session and then a prompt, a planted credential carried into an allowed write, a secret sent base64 encoded after a labelled read, a retry loop past a held budget, and a revoked owner with an open session and a re-minted token. Each refusal leaves the target upstream untouched and the journal without the adversarial text, and each case fails when its control is disabled (
adversarial_workflow_tests::prod_logs_read_in_one_session_cannot_leave_through_another,adversarial_workflow_tests::confluence_injection_cannot_carry_its_planted_credential_into_a_write,adversarial_workflow_tests::tool_result_secret_cannot_reach_a_model_even_encoded,adversarial_workflow_tests::runaway_tool_loop_is_held_at_its_budget_not_run,adversarial_workflow_tests::revoked_owner_is_stopped_mid_workflow_on_both_paths). -
Every representation, before any copy (decision 16, PR 3). A
tool_resultrule inspects a result’s text blocks andstructuredContenttogether at the result boundary, before the structured value is bounded and before a task record or an overflow artifact holds it;redactrewrites both,denyleaves nostructuredContent(content_guard_tests::oversized_structured_result_is_inspected_before_the_overflow_artifact_is_written,background_task_tests::oversized_background_result_is_inspected_before_spill). -
In flight on the inference path (PR 3).
promptrules review every decoded string of the body before forwarding;completionrules review streamed deltas over a lookback window before relay and terminate on a finding, or hold the whole response inbuffermode and release all or nothing (inference_guard_tests). An event past the 1 MiB event bound is never held and is refused under the rule’son_error(inference_guard_tests::an_event_past_the_event_bound_is_refused_not_relayed_uninspected). Prompts and completions are never persisted (inference_guard_tests::sensitive_values_never_reach_storage_or_logs). -
External engines on the rule’s terms (PR 3). The webhook adapter is bounded in time, size and subject, fails under the rule’s
on_error, and applies the engine’s answer under the rule’s own action (inference_guard_tests::webhook_timeout_follows_rule_on_error).
Not met, by design: base64 block payloads and binary artifacts are not decoded, the built-in patterns detect credential shapes only, and bytes a stream relayed before a finding are gone. The request-side policy remains the provable boundary; content rules are enforcement with stated detection limits.
Dependency advisory remediation
Section titled “Dependency advisory remediation”MCP04: keep the strict Cargo Deny gate unsuppressed. The lockfile uses patched
ctutils, rustls-webpki, webpki-root-certs, zstd, zstd-safe and zstd-sys. Askama
keeps derive/std/urlencode but disables config parsing to remove unmaintained
basic-toml; console templates live in crates/console/templates/. The source
cache fingerprints this default directory (tests/build_cache_test.py).
Template contents and HTML escaping are unchanged;
unified_console_tests::pages_render_the_api_answers_and_never_the_token
checks rendering and escaping.
The self-rooted fuzz crate has its own lock, which remediation of the root
lock does not reach. Regenerate fuzz/Cargo.lock from the root lock after
every root change (fuzz/README.md);
binary_tests::fuzz_lock_resolves_shared_crates_to_root_versions fails on
drift, and the quality job audits the fuzz-only crates against
fuzz/deny.toml. Dependabot (.github/dependabot.yml) proposes weekly cargo
updates with a seven-day cooldown, and its PRs need the same regeneration.
Served skills
Section titled “Served skills”With MCP_SKILLS_ENABLED=true the server publishes embedded, reviewed
workflows through the MCP Skills extension (MCP Skills). Every
Skills method uses one gate: the live opt-in, a 2026-07-28 request, and the
validated principal; client _meta is never identity. A skill exists for a
session only when every tool it names is on that session’s surface, and
knowing its URI changes nothing. URIs and cursors are parsed by a fuzzed
canonical grammar and resolved by exact in-memory lookup, so no request
reaches a filesystem or the network. Content is an explicit allowlist
compiled into the binary, validated as a whole at startup (no
allowed-tools, no scripts, bounded sizes, digests over the exact bytes),
identical for every principal and tenant, and returned verbatim with
cacheScope: private.
Following a skill grants nothing: each tool call it suggests passes the same
admission, credential selection, policy and approvals as any other call. The
specification’s host duties (activation consent, origin binding,
digest/frontmatter verification, never honoring permission-widening
frontmatter) are not something this server can enforce; digests show
consistency with the manifest, not trustworthiness. skills_wire_tests,
skills_packaging_tests and skill_uri_invariants_tests lock these
boundaries.
Scripted evaluation dependency boundary
Section titled “Scripted evaluation dependency boundary”MCP04 also covers the excluded eval/ test consumer: exact direct pins, a
self-rooted lock held to root versions by
binary_tests::eval_lock_resolves_shared_crates_to_root_versions, and its own
CI advisory gate. It introduces no eval-exclusive Rust registry versions; the direct process-control
pin follows the root lock. The reference-runtime adapter additionally has a
hashed Python lock, exact SDK/CLI/Python pins and an advisory audit. Linux CI
requires loopback-only namespace execution (runner qualification pending); see the compatibility record. The
scripted workflow provides additional containment evidence via
workflow_tests::paired_runbook_workflow_proves_containment_and_benign_completion;
it does not establish how a live agent behaves or alter production controls.