Skip to content

Threat model

First reviewed 2026-09-25; implementation and deployment claims reconciled 2026-10-08 for the unified product, and 2026-10-09 for the default WAF tier. The original review closed the public half of WP 0.2 and recorded the checkpoint threat model (CF-23). The OWASP MCP Top 10 assessment complements this STRIDE model with deployment prerequisites, responsibility boundaries, and prioritized follow-up work.

This document says what mcp-devtools defends, against whom, by which mechanism, and which test locks that mechanism in place. Where a threat is not mitigated, it says so and names the operator control or the tracked issue instead. It complements two other documents rather than repeating them: mcp-security-best-practices.md is the conformance map against the MCP specification’s own security section, and SECURITY.md is the disclosure process. The console, two-person approval and configuration editor are also covered by the control and console threat model; see unified product migration for the current feature and crate boundaries.

The method is STRIDE per trust boundary: for each boundary, the spoofing, tampering, repudiation, information-disclosure, denial-of-service, and elevation-of-privilege threats that matter, each with its mitigation and the test that would fail if the mitigation were removed. A row whose “Locked by” column is empty is a gap by definition, and is repeated in §7.

An MCP client (an agent runtime such as Claude Desktop or Codex) calls tools on this server. The tool catalog exposes vendor API, database, download, and clone operations; README.md is the authoritative integration inventory; there is no generic HTTP tool, no shell tool, and the server never spawns or proxies other MCP servers. The server holds the vendor credentials; inbound bearer tokens are never forwarded as vendor credentials. Canonical API targets cannot supply a host; upstream download and Git destinations have separate controls and residual risks described in B4. In local mode (stdio, or HTTP bound to loopback with MCP_AUTH_MODE=off) every caller is the local user. In enterprise mode (HTTP, MCP_AUTH_MODE=oidc) each request carries a bearer token validated against the configured identity provider; a signed policy document decides which principal may perform which action on which resource, defaulting to deny; every call’s intent is written to a durable, hash-chained audit journal before dispatch and its outcome after; and the journal is sealed by signed checkpoints and, when forwarding is configured, shipped to a SIEM. The plan’s ADR-011 puts the reason plainly: the product sells decision evidence.

# Asset Why it matters Where it lives
A1 Upstream vendor credentials The reason the server exists: the client gets vendor access without ever holding a credential OS keychain, or resolved from file:// / vault:// / awssm:// / azkv:// references into process memory (src/secrets/, crates/identity/src/auth/credential_broker.rs)
A2 Inbound access tokens Prove who the caller is; also the “fresh token” for write operations Request headers only; validated, cached by validation result, never forwarded upstream (src/ports/token_validator.rs)
A3 Policy and revocation documents, and their signing key Decide every allow; a forged policy is a full bypass Files on the gateway, Ed25519-signed; the private key is held by the policy author, never by the gateway (crates/policy/src/policy/signing.rs)
A4 The audit journal and its checkpoint signing key The evidence; the audit key is what makes a checkpoint mean something MCP_AUDIT_JOURNAL_DIR, MCP_AUDIT_SIGNING_KEY, MCP_AUDIT_CHECKPOINT_EXPORT_DIR; forwarded copies in the SIEM
A5 Upstream response content and artifacts Customer data flowing through the gateway; cached and stored per principal Response cache, /tmp artifact store (src/application/artifacts.rs)
A6 Configuration and secret references A reference names where a credential lives; the configuration names every trust anchor (issuer, JWKS, public keys) Config file, environment
A7 Availability A gateway that is down denies every agent in the organisation; fail-closed design means integrity is bought with availability Process, journal volume
B1 B4
MCP client ─────────▶ ┌──────────────────────────┐ ─────────▶ Vendor APIs
(agent runtime) │ mcp-devtools │ (Jira, GitHub, …)
│ │
Identity provider ◀── │ B2 token validation │ ──▶ B5 Secret sources
(JWKS, discovery) │ policy (B3, files) │ (keychain, Vault,
│ audit journal │ AWS SM, Key Vault)
└──────────┬───────────────┘
│ B6
journal volume · checkpoint export · SIEM
│
B7 host operator / orchestrator (reads and writes all of the above)
B8 build and release pipeline (produces the binary and image)
Boundary What crosses it Direction of trust
B1 MCP requests and responses; bearer token; session id The server trusts nothing from the client except a token it can validate
B2 JWKS and discovery documents The server trusts the configured issuer’s keys and nothing else
B3 Policy and revocation documents The server trusts documents signed by MCP_POLICY_PUBLIC_KEY; the author holds the private key
B4 Outbound HTTPS with vendor credentials; response bodies and redirects Vendor content is untrusted. Download locations and clone metadata can influence subsequent I/O; HTTP redirects are constrained, and a clone URL is bound to the requested host and repository before Git runs
B5 Credential material The server trusts the source it was configured with, over TLS, with bounded responses
B6 Journal lines, checkpoints, forwarded records The server writes; a verifier elsewhere reads. Trust in the evidence depends on where the copies are kept
B7 Files, environment, memory, disk The host is trusted for confidentiality of what is in memory and for availability; it is not trusted for the integrity of the evidence, which is the point of §6
B8 Source, dependencies, toolchain, image base Pinned, license- and advisory-gated, signed on release
B9 Model requests and streamed completions through the inference routes (/v1/*), under the provider credential The client is trusted as far as its validated token; the body is opaque bytes; the provider’s response is relayed, not interpreted, beyond its usage frames
# Attacker Position Goal
T1 Unauthenticated network peer Can reach the HTTP port, or a browser on the same machine as a local server Call tools; exhaust the process; learn what is deployed
T2 Authenticated caller Holds a valid token, or drives an agent that does. Includes an agent whose prompt has been injected through upstream content Act beyond their policy; read another principal’s data; leave no trace
T3 Malicious or compromised upstream Controls a vendor endpoint’s responses, or a redirect target Steal the credential; steer the agent; exhaust the gateway
T4 Policy author or administrator Holds the policy signing key or an mcp:admin token Widen access quietly; erase what they did
T5 Host operator, or a compromised host Root on the gateway host or the orchestrator; can read memory and rewrite disk Read credentials; forge or erase evidence; both without detection
T6 Supply chain Can influence a dependency, the toolchain, or the release pipeline Ship a modified binary

Not modelled: the identity provider itself as an adversary (it can mint any token; the design places it inside the trust base and the revocation list is the operator’s lever against a compromised account, not a compromised issuer), and a vendor acting against its own customer’s data.

Test names are files under tests/ unless a module path is given. “Structural” means the property follows from a type or a build-time check rather than a runtime branch, and the test cited proves the structure holds.

Threat STRIDE Mitigation Locked by
Evaluation runtime inherits credentials, tools/plugins or extra servers, or leaves children after cancellation S, I, E, D Evaluation only: cleared environment, private cwd/HOME, strict single MCP, built-ins/hooks/plugins off, pinned SDK/CLI, bounded pipes/turns/deadlines and owned process group. Startup/provider tool inventories, versioned source/configuration/fixture/policy manifests and joined journal/destination evidence are required. An explicit runtime API refusal must match the final same-owner gateway deny; incomplete trials never produce negative attack verdicts. Linux CI requires loopback-only namespace/interface/route preflight (runner qualification pending); no production boundary changes. Native compromise, escaped groups and supervisor SIGKILL/OOM remain host responsibilities runtime_tests::runtime_routes_both_paths_under_one_owner_with_isolated_credentials_and_tools, runtime_tests::runtime_cli_ignores_poisoned_parent_credentials_configuration_and_plugins, runtime::tests::cancellation_timeout_and_leader_exit_kill_owned_descendants, runtime_tests::runtime_refusal_requires_terminal_gateway_and_destination_evidence, runtime::tests::provider_forbidden_without_policy_denial_is_an_incomplete_runtime_failure
Maintainer changes advertise an incorrect vendor operation T Compiled tool descriptions are reviewed and pinned; Bitbucket PR updates explicitly use PUT rather than PATCH tool_surface_parity_tests::tool_surface_matches_golden, ::bitbucket_pr_update_guidance_uses_put
Client mistakes capability support for identity or a grant restriction S, E HTTP-only startup validation requires inbound OIDC; an explicit authenticated-session flag gates discovery, which reads live configuration and withdraws advertisement on invalid reloads. Both security opt-ins reject conflicting vendor values. Ordinary tokens use the same scope, revocation, policy and machine-admin boundary; acquisition stays at the AS oauth_tasks_compatibility_tests::client_credentials_discovery_requires_authenticated_http_and_preserves_initialize; oauth_tasks_compatibility_tests::client_credentials_discovery_follows_config_reload_and_fails_closed; ema_tests::ema_opt_in_refuses_conflicting_vendor_configuration; admin_principal_kind_tests::advertised_client_credentials_reuses_token_scope_and_revocation_checks
A client reads files outside the skill catalog (traversal, encoded separators, other schemes) or a skill its session cannot use I, E Canonical fuzzed skill:// grammar, exact in-memory lookup with no filesystem or network access; a skill is visible only when its tools are on the session’s surface, applied identically to listing, skills/get and resources/read skill_uri_invariants_tests, skills_wire_tests::stdio_rejects_malformed_unknown_and_oversized_requests, ::skills_whose_tools_the_session_lacks_do_not_exist_for_it
Served skill content asks the host for permissions or steers the agent E (of the agent) Partially mitigated. Skills are opt-in, embedded and reviewed; allowed-tools, unknown frontmatter and scripts are refused at load; following a skill grants no server permission. Host consent and origin binding remain client duties application::skills::tests::invalid_packages_are_refused_whole, skills_wire_tests::disabled_surface_is_unchanged
T1 calls tools without a token S, E Enterprise mode requires a bearer on every MCP route; missing or invalid yields 401 with an RFC 9728 challenge and nothing is executed inbound_auth_tests::missing_bearer_is_401_with_a_resource_metadata_challenge, ::auth_required_without_a_principal_refuses_rather_than_running_as_local
T1 presents a forged, expired, or wrong-audience token S RS256 pinned; iss, aud, exp, nbf, iat checked; keys only from the configured JWKS or file, jku/x5u headers ignored; a token from the future is refused token_validator_tests::the_negative_matrix, ::jku_and_x5u_headers_are_ignored_in_favour_of_the_configured_jwks, revocation_tests::a_token_issued_in_the_future_is_refused_before_revocation_or_freshness_see_it
T2’s token is revoked or their group membership withdrawn, but the token has not expired S, E Signed revocation list polled every interval; per-subject and revoke-all cut-offs; sessions of a revoked subject closed; group removal takes effect on the next token, which the write-freshness rule bounds revocation_tests::a_revoked_subject_is_refused_within_a_poll_and_the_refusal_is_journaled, ::revoke_all_refuses_tokens_issued_before_the_cutoff_and_closes_their_sessions, ::group_removal_waits_for_the_token_but_revocation_does_not
T2 replays an old token for a write E Non-read calls require a token younger than MCP_WRITE_MAX_TOKEN_AGE_SECONDS; reads do not revocation_tests::non_read_calls_need_a_fresh_token_reads_do_not
A caller uses a CI wait tool to reach an endpoint other than one run’s status, or to hammer the vendor E, D *_wait_* tools splice only validated single segments, u64 ids or a CircleCI UUID into fixed run/pipeline/workflow/build templates; every poll is re-authorized at egress; timeout 10-3600 s and interval 5-300 s are enforced, 429/502-504 back off with Retry-After; typed transport failures retry at the poll interval within the same schedule, permanent errors and policy refusals end the wait, and dropping the call stops polling ci_wait_tests, ci_wait::tests, native::tests::only_known_routes_and_tools_are_reads
A Jira attachment supplies archive instructions, unsafe filenames, excessive bytes or a credential-stealing redirect I, T, D, E jira_download_attachment uses numeric IDs and a fixed content route; shared streaming bounds, disk quotas and egress authorization; cross-origin allowlist and credential stripping; generated owner-bound artifact filenames; metadata-only results and no archive extraction. Unclassified calls remain denied under enforcing file policies jira_download_tests, background_task_tests::jira_download_supports_tasks_and_synchronous_artifact_retrieval, tool_surface_matches_golden
A caller uploads to the wrong issue, reads a foreign artifact, or retries an uncertain Jira attachment write E, I, T jira_upload_attachment validates the issue as one segment, uses bounded shared file preparation and owner-bound artifact reads, and authorizes the concrete POST through multipart egress. No client-local paths or automatic retries; uncertain outcomes require checking existing attachments jira_upload_tests, multipart_transport_tests
Rotation restores stale revocations, leaks a signing key, or executes an unintended service command E, I, T, R Operator-only preparation verifies all old signatures and bindings, preserves policy/revocation bytes, keeps a digest-only recovery record, refuses policy activation while the replacement private key is in the rotation directory, refuses activation after a failed rollback, binds activation to one systemd user service and EnvironmentFile, and rejects drift/compromised-key rollback. Console inventory is admin-only and cannot execute commands. Local transition records are recovery evidence, not signed gateway audit records crates/control/tests/rotation.rs, configuration_admin_tests, unified_console_tests::rotation_console_is_an_authenticated_operator_handoff_without_private_keys, rotation::policy_activation_refuses_while_the_private_key_is_in_the_rotation_directory, rotation::recovery_record_holds_no_environment_contents, rotation::a_failed_rollback_is_resumed_only_by_rollback, rotation::tests
T2 uses another principal’s session id, artifact handle, or cached response S, I Sessions, artifacts, and the response cache are bound to the principal that created them, with or without an audit journal (a journal-less call runs under an owner-only task-local, not the enforcing call scope); a foreign handle is 404, never 403, so existence is not disclosed; bb_upload reads only the caller’s artifacts principal_partitioning_tests::a_session_is_bound_to_the_principal_that_initialized_it, ::artifact_download_is_owner_only_and_cross_owner_is_404, ::the_response_cache_is_partitioned_by_principal, ::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
T2 acts on a resource the policy does not name E Default deny at two points: the tool-level decision before dispatch and the egress decision on the canonical request the transport is about to send. An unclassified request is denied before rules are consulted; an unknown resource is denied policy_engine_tests::golden_decision_table, policy_cedar_parity_tests (both decision points agree), phase_a_vertical_slice_tests
T2 smuggles a target host or path through a tool argument E, S Tool arguments cannot name a host: the vendor client builds the URL from configuration and the extractor classifies the canonical request. Canonicalisation is bounded and fuzzed canonicalization_tests::seeded_corpus_satisfies_every_invariant, ::canonical_form_is_bounded_like_the_input, fuzz/fuzz_targets/canonicalize.rs, extractor_invariants_tests
T2 claims to be an operator or a machine to widen policy E The principal’s kind is read from validated claims per identity-provider profile; a client-supplied marker never outranks the token token_validator_tests::subject_kind_operator_markers_never_outrank_a_machine_reading, admin_principal_kind_tests
T2 denies having made a call R Intent journaled and synced before dispatch with the principal and upstream identity; outcome after. A journal that cannot accept the intent refuses the call, so there is no unjournaled call audit_journal_tests::intent_with_upstream_identity_is_journaled_before_dispatch_and_outcome_after, ::failed_journal_write_refuses_the_call_and_never_contacts_the_vendor
T1 floods the port D 1 MiB request body cap; per-principal token bucket keyed on the validated subject, so nobody can spend another’s budget; the bucket map is hard-bounded and refuses rather than grows; idle sessions reaped http_transport_tests::body_over_1mb_is_rejected_with_413, ::idle_sessions_are_reaped_after_ttl, admin_api_tests::admin_limiter_is_independent_of_mcp, src/server/rate_limit.rs unit tests
T1 in a browser on the same machine drives a local server (DNS rebinding, cross-site) S, E Origin allowlist on the transport: a bad Origin is 403; the HTTP transport refuses to bind a non-loopback address with authentication off; stdio refuses to start in a mode it cannot honour http_transport_tests::bad_origin_is_rejected_with_403, auth_mode_tests::http_refuses_non_loopback_bind_with_auth_off, ::stdio_refuses_okta_mode
A local classifier artifact is replaced, a download redirects credentials, or a subject exceeds runtime limits T, I, D, E Opt-in loopback service; authenticated inspection; pinned revisions and hashes; bounded HTTPS downloads with public DNS and CDN credential stripping; atomic cache publication; GPU-only Safetensors or explicitly selected ONNX CPU/CUDA, with warmup before readiness; CUDA defaults to refusing CPU graph nodes, optional mixed placement requires operator opt-in, provider-loss downgrades and execution retries are refused. Registration alone is not placement evidence; opt-in qualification requires traced CUDA kernels (test_onnx_gpu.py::test_session_provider_and_fallback_contract, test_onnx_gpu.py::test_unavailable_cuda_refuses_before_artifact_fetch); complete-subject inspection, bounded windows and killable native worker. Liveness-aware readiness, idle heartbeats and lifetime-bounded restarts reverify/warm each replacement; failed subjects are never replayed. Cancellation joins worker-group cleanup, including leader-first death; cgroup containment for supervisor SIGKILL/OOM/escaped sessions is required and not yet staging-validated. The reviewed trained ONNX profile pins every artifact with SHA-256 and has no external tensor files; its curated CPU evaluation records misses/false positives and preserves operator policy authority. Native model code and other profiles’ external-data paths remain trusted; isolate the service account test_supervision.py::test_idle_crash_recovers_then_exhausts_lifetime_budget, test_supervision.py::test_repeated_cleanup_cancellation_kills_descendants_after_leader_exit, inference_guard_tests::supervised_local_guard_failures_preserve_gateway_policy, test_artifacts.py::test_trained_model_cdn_strips_repository_authorization, test_evaluation.py::test_reviewed_profiles_pin_every_file_with_sha256, test_real_model.py::test_trained_cold_download_http_and_offline_reuse, inference_guard_tests::trained_local_model_gateway_preserves_policy_actions_and_failures, test_artifacts.py::test_redirect_strips_repository_token_and_streams_file, test_local_guard.py::test_cache_tampering_refuses_offline_and_repairs_online, test_local_guard.py::test_deadline_and_cancellation_reap_actual_process, test_local_guard.py::test_window_limit_fails_instead_of_truncating. 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.
T2 learns the token or a credential from an error I Rejected tokens are never logged; transport errors are redacted; the redactor covers vendor token shapes, Authorization headers, JWTs, and URL userinfo, and replaces the union of every pattern’s matches on the original line, so one pattern’s replacement cannot hide another’s match (the shared detector in devtools_contracts::guard::secrets). TLS audit receiver validation errors name only the expected scheme and setting, never raw URL contents inbound_auth_tests::rejected_token_is_401_invalid_token_and_never_logged, src/logger.rs tests redacts_jwt, redacts_authorization_bearer_header, redacts_url_embedded_basic_auth_keeping_scheme, secret_detector_invariants_tests::overlapping_matches_are_redacted_as_one_span, audit_forwarding_tests::relp_configuration_rejects_plaintext_credentials_and_extra_url_parts
A model opens pages through the browser tools: on a shared host, against internal addresses, or past the host’s memory E, D, I Over HTTP the tools exist only behind PLAYWRIGHT_HTTP_ENABLED with a declared PLAYWRIGHT_CONTAINMENT (an allowlist forward proxy the browser is pointed at with the loopback bypass removed and WebRTC held to proxied transports, Chromium-based engines only; a network policy alone is refused because it cannot block the pod’s loopback), proven by the playwright-containment CI job; handles are bound to the validated bearer subject on every call and a revoked subject’s browsers close; caps are admitted before spawn under concurrent multi-tenant load with a flat gateway resident set; a per-child cgroup memory ceiling where writable, and a kernel-killed browser is a dropped handle; one replica. Each page is a separate headless child with an in-memory profile and an allowlisted environment; only http/https URLs without user-info are opened and the outcome record keeps only the origin and path; handles are bound to the opening principal; per-owner and global caps are admitted before launch and a closing browser keeps its slot until teardown completes; idle sessions are closed; a child that hangs, exits or writes a line above the message cap is killed and its handle dropped; page text is bounded and scrubbed before the model sees it http_transport_tests::http_refuses_playwright_tools_before_launching_anything, tool_surface_parity_tests::stdio_advertises_playwright_tools_when_selected, playwright_controller_tests, playwright_bridge_tests, browser_snapshot_invariants_tests
An HTTP caller tries to clone into the server’s filesystem E, T Every HTTP session omits bb_clone and refuses direct calls before admission, filesystem writes or Git execution. Local stdio and direct CLI retain the hardened clone controller http_transport_tests::modern_tools_list_is_stateless_deterministic_and_cacheable, ::http_refuses_bb_clone_before_creating_a_destination; tool_surface_parity_tests::stdio_advertises_bb_clone_when_bitbucket_is_enabled
A clone’s Git traffic leaves no record of where it went R Git’s network access passes no egress chokepoint, so the call scope records each network-reaching subprocess before it is spawned, with the validated, credential-free URL it was handed, and its exit; the outcome record carries the list (subprocesses), so a clone the call outlived still shows started clone_tests::the_call_scope_records_where_git_was_pointed_and_how_it_ended, ::a_refused_clone_url_records_no_subprocess, cli_audited_tests::bb_clone_journals_the_git_destination_on_its_outcome
Request diagnostics expose customer URL contents or spoof correlation I, R HTTP diagnostics use route templates, fixed unmatched/custom-method markers and server-generated IDs; omit queries, headers except bearer presence, bodies and claims. Arrival and response-header events cover discovery and refusals; diagnostic retention is bounded and does not replace the audit journal inbound_auth_tests::http_diagnostics_correlate_discovery_and_refusals_without_request_secrets
T1 sends an attack payload (SQL injection, XSS, path traversal, a scanner) at the console, the admin API or the MCP envelope T, E Defense in depth in front of the server’s own validation: every shipped deployment path (dev, demo, deploy/compose, the Helm chart, deploy/k8s) puts a WAF in front of it by default, ModSecurity with the OWASP CRS at paranoia level 1 in blocking mode, one shared ruleset in deploy/waf/. Tool-call arguments and administrator-authored policy content are exempt from the injection rule groups (they are code-like by design; policy and approvals govern them), so the WAF is not an authorization control scripts/ci/waf-smoke.sh (CI image job), scripts/ci/compose-smoke.sh, operations_tests::every_shipped_proxy_runs_the_waf
Oversized bodies exceed WAF inspection or recreated Services strand routing T Helm refuses proxy caps above inspection; ModSecurity rejects oversized bodies without partial inspection. Kubernetes resolves fixed complete Service names at runtime using pod DNS, preserving the read-only root and dropped capabilities operations_tests::helm_waf_body_limit_is_validated_and_rolls_pods, scripts/ci/waf-smoke.sh, scripts/ci/waf-dns-smoke.sh
Local demo credentials are accidentally staged I Git ignores the exact demo token path; credentials should still live outside the checkout operations_tests::local_demo_credentials_and_python_bytecode_are_ignored
T1 reaches the server around the WAF E The Compose stacks publish only the proxy’s port; with networkPolicy.enabled the chart admits only the WAF pods to the roles (peers in networkPolicy.ingress.direct are the explicit exception), and the WAF pods may open only DNS and the roles’ port. The .deb/.rpm packages and the bare image ship no proxy and bind to loopback operations_tests::every_shipped_proxy_runs_the_waf, ::helm_network_policy_allowlists_egress_and_blocks_the_metadata_endpoint
The WAF’s own log captures a bearer token, a session cookie or tool arguments I The Authorization header and the console cookies are never rule targets, and the WAF audit log keeps only rule messages, never request or response headers or bodies (parts A, H, Z), so neither a bearer token nor the console’s Set-Cookie reaches it scripts/ci/waf_cases.py case credentials are not rule targets, scripts/ci/waf-smoke.sh session-cookie check; deploy/waf/engine.conf, exclusions-after-crs.conf
Operator misconfigures the mode and silently runs open E An unrecognised MCP_AUTH_MODE is refused, not defaulted to off; enterprise mode refuses to start without a policy public key, a revocation list, an authorization-generation manifest, an audit signing key, and a journal auth_mode_tests::unrecognised_auth_mode_is_refused_not_defaulted_to_off, ::http_refuses_okta_mode_without_an_audit_signing_key, ::http_refuses_okta_mode_without_a_policy_public_key, ::http_refuses_okta_mode_without_a_revocation_list, ::http_refuses_okta_mode_without_an_authorization_generation
Threat STRIDE Mitigation Locked by
An attacker on the path serves a substitute JWKS or discovery document S, T HTTPS required except loopback; no redirects followed; JWKS admission limited to RSA signing keys with unambiguous kids; oversized documents refused token_validator_tests::jwks_fetch_uses_bounded_secure_validated_documents, ::jwks_admission_is_rsa_signing_keys_with_unambiguous_kids, ::oversized_jwks_is_refused_whether_or_not_its_length_is_declared
The issuer rotates or withdraws a key S Unknown kid triggers one rate-limited refetch; withdrawing a key evicts every token it validated from the cache; republishing a kid with new material does the same token_validator_tests::key_rotation_is_picked_up_on_refetch, ::withdrawing_a_key_evicts_tokens_it_validated_from_the_cache, ::republishing_a_kid_with_new_material_evicts_tokens_it_validated
The issuer is unreachable D Fail closed for new validations; already-validated tokens keep serving from the cache while a background refresh fails, until their own expiry token_validator_tests::unreachable_jwks_fails_closed_and_recovers, ::stale_keys_keep_serving_while_a_background_refresh_fails
Air-gapped deployment cannot fetch keys at all D File-backed JWKS with explicit configuration; a file configuration excludes remote override, so a file cannot be silently replaced by a fetch token_validator_tests::file_jwks_configuration_is_explicit_and_excludes_remote_override, ::file_keys_rotate_withdraw_and_fail_closed_even_for_cached_tokens
Threat STRIDE Mitigation Locked by
T5 (or T4 without the key) edits the policy file on disk T, E Every load, at startup and on change, verifies an Ed25519 detached signature against MCP_POLICY_PUBLIC_KEY; unsigned or mis-signed documents are refused and the last good one stays in force with health degraded policy_signing_tests::a_verifying_key_refuses_unsigned_and_missigned_documents, policy_engine_tests::a_changed_document_is_picked_up_and_a_broken_one_is_ignored, ::a_vanished_policy_file_keeps_the_last_policy_and_reports_degraded_health
A policy signing key is used to forge audit evidence, or vice versa E Domain-separated signatures; the policy key and the audit key are distinct by design, so a compromised gateway cannot publish policy and a policy author cannot forge checkpoints policy_signing_tests::keygen_sign_and_check_round_trip_at_the_binary_boundary, audit_checkpoint_tests::the_verifier_catches_edits_forgeries_wrong_keys_and_a_truncated_tail
T4 changes policy and denies it R The policy in force is journaled before the server serves; every change and every rejection is a journal record; a change whose record cannot be journaled is not applied policy_signing_tests::a_server_journals_the_policy_in_force_before_it_serves_or_does_not_serve, ::a_change_is_journaled_before_it_takes_effect_and_rejections_once, ::a_change_whose_record_cannot_be_journaled_is_not_applied
The signing key file is readable by other users I sign and the server refuse a key file other users can read policy_signing_tests::sign_refuses_a_key_file_other_users_can_read
Policy and revocation list are applied as a mismatched pair, or an older pair is replayed E Authenticated HTTP mode requires one signed authorization-generation manifest binding both documents. A newer generation is journaled before both halves become active together; each decision rechecks revocation under its generation. CF-24 is closed for this mode authorization_generation_tests::both_halves_take_effect_together_and_only_under_a_newer_manifest, ::a_replayed_older_generation_cannot_unrevoke_anyone, ::a_generation_whose_change_cannot_be_journaled_is_not_applied, ::decisions_recheck_revocation_and_carry_the_generation_they_were_taken_under
Threat STRIDE Mitigation Locked by
PostgreSQL connection failures hide the cause or expose credentials in diagnostics I, R Warning-level destination, TLS mode and transport cause chain; no connection config dump; selected username/password masked in client-side causes; server ErrorResponse logged as SQLSTATE only. Operator logs retain host/database names and supplement the audit outcome postgres_connection_tests::refused_connections_log_destination_and_underlying_io_cause, ::tls_rejection_logs_underlying_cause, ::server_rejection_logs_sqlstate_without_server_text_or_credentials
A caller uses the NinjaOne explain option to run a write, or to inject an EXPLAIN option T, E sql keeps the single-SELECT guard; the EXPLAIN prefix is built from four typed flags in a closed schema; analyze runs in the read-only session and transaction under the statement timeout, so the server refuses a write or writer function; rowLimit does not bound a plan, only the timeout does ninjaone_db::tests::explain_is_still_guarded_before_connecting, ninjaone_db::tests::explain_statement_renders_only_typed_flags, application_boundary_tests
The client’s token is forwarded upstream (token passthrough) I, S The inbound token and the upstream credential are different types; the credential broker selects an upstream credential by principal and target, and the inbound token has no path to a vendor header credential_selection_tests, vendor_secrets_tests, mcp-security-best-practices.md §“Token passthrough”
T3 redirects to a host of its choosing to capture the credential I Control-plane clients never follow redirects. Download redirects are explicit and bounded: cross-origin only when the origin is in MCP_DOWNLOAD_ALLOWED_ORIGINS, and API headers are stripped when crossing origins; every redirect is authorised by origin and path before dispatch integration_download_tests::github_log_redirect_strips_credentials_and_returns_artifact, ::custom_token_redirect_is_stripped_and_relative_loops_are_bounded, ::redirects_are_authorized_by_origin_and_path_before_dispatch
T3 returns an enormous or endlessly compressed body D Per-vendor and global response caps (MAX_API_RESPONSE_SIZE, streamed artifact cap, decompression bounded); oversized content truncated and annotated rather than buffered truncation_tests::oversized_content_is_truncated_and_annotated, integration_download_tests::compressed_error_expansion_is_bounded_on_streaming_and_shared_paths, ::artifactory_download_checks_sha256_and_limits
T3 echoes a signed URL or credential in an error body that reaches the client I Redirected error bodies do not echo signed URLs; Figma exports never expose signed URLs; binary artifacts never produce text previews integration_download_tests::redirected_error_bodies_do_not_echo_signed_urls, ::figma_exports_png_and_marks_missing_nodes_without_exposing_signed_urls, ::binary_artifacts_never_collect_text_previews
T3 plants instructions in content that steer the agent into further calls E (of the agent, not the server) Partially mitigated. Results are not interpreted; what a steered agent can then do is bounded by the policy’s default deny and the write-freshness rule: a follow-up call policy denies makes zero upstream requests. A schema 3 content rule also inspects the decoded arguments of an allowed write before dispatch, so a planted credential cannot be posted out. A flows rule refuses a write or prompt once the owner has read a classified or labelled result, across sessions and paths; other content in an allowed write is journaled, not prevented. See §7 prompt_injection_tests::injected_instructions_cannot_make_a_denied_slack_write_reach_slack, ::an_allowed_read_followed_by_an_allowed_write_is_journaled_not_prevented, ::injected_secret_in_tool_result_cannot_be_posted_by_follow_up_write, 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
T2 (or a steered agent) exfiltrates a credential through the arguments of a write policy allows, spelled with JSON escapes to slip past byte matching I Schema 3 content rules: after admission and before the approval gate and intent, the built-in detectors read every decoded string of the parsed arguments (keys included); a finding denies (the policy-denied envelope naming rule and detector, never the value), holds for approval with the findings digest in the hold key, or labels. Findings are journaled as metadata before the intent content_guard_tests::secret_in_issue_create_is_denied_with_zero_upstream_requests, ::json_escaped_secret_in_write_argument_is_denied, ::hold_action_routes_to_approval_and_reissue_must_match_findings, secret_detector_invariants_tests (and the secret_detector fuzz target)
T2 sends arguments too large to inspect, or the guard is unavailable, so a content rule cannot run T, D Fail mode is policy (decision 4): each rule’s on_error is deny by default; MCP_GUARD_MAX_SUBJECT_BYTES bounds inspection time; a rule this build cannot enforce is refused at load, and NoopGuard answers unavailable rather than allow content_guard_tests::guard_error_denies_by_default_and_labels_when_configured, policy_engine_tests::schema_v3_refuses_what_this_build_does_not_enforce
T3 returns a credential in a result that the agent would carry into its next call, in the text or only in structuredContent, or past the structured bound into an overflow artifact I A tool_result content rule inspects every representation of a complete result at the one result boundary, before the structured value is bounded and before the task record or any spill holds it (decision 16): deny replaces the result with the policy envelope and no structuredContent, redact rewrites text blocks and the structured value (keys included), label records the classes on the outcome; the finding is journaled first and an unjournaled finding withholds the result content_guard_tests::tool_result_label_and_redact_actions, ::structured_content_is_redacted_together_with_the_text, ::denied_result_carries_no_structured_content, ::oversized_structured_result_is_inspected_before_the_overflow_artifact_is_written, background_task_tests::background_task_result_is_guarded_before_spill, ::oversized_background_result_is_inspected_before_spill
An unexpected code path names reqwest and bypasses the transport’s controls E Structural: only the transport adapter may name the HTTP client outbound_http_boundary_tests::reqwest_is_named_only_by_the_transport_adapter, hexagonal_boundary_tests
T3 returns clone metadata pointing git at another host, another repository, an option-shaped host, or a non-network transport, or redirects git’s first HTTPS request elsewhere I, E The clone URL is parsed structurally and must name the Bitbucket host the vendor base URL implies and exactly the workspace and slug the call asked for; userinfo, queries, extra path and --prefixed components are refused; git runs with protocol.allow=never plus SSH/HTTPS allows, http.followRedirects=false, -- before the URL, hooks disabled, and an allowlisted environment so no inherited GIT_* variable can steer it 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, ::git_refuses_a_redirect_on_its_first_request, shell_tests::system_command_runner_applies_an_allowlisted_environment
Control-plane fetch to a private address (SSRF) I HTTPS-or-loopback and no redirects constrain fetches; a compromised discovery document can still name an unwanted HTTPS destination; private-IP range blocking is an operator control (network policy), not code. Deviation 2 in the best-practices map token_validator_tests::jwks_fetch_uses_bounded_secure_validated_documents; operator: deploy/helm networkPolicy.enabled

The browser tools (playwright_*, opt-in playwright feature) are the same kind of boundary: a managed child whose network traffic never passes the HTTP enforcement adapter. The call’s outcome record names the URL each session was opened on, as it does for Git, and under a policy file playwright_open is scoped to the host being opened. Containment itself is a deployment control the gateway refuses to run without over HTTP: PLAYWRIGHT_CONTAINMENT=proxy points the browser at the operator’s allowlist proxy for every request, loopback included (--proxy-bypass "<-loopback>"), with WebRTC held to proxied transports. Those are Chromium switches, so with a proxy only a Chromium-based PLAYWRIGHT_BROWSER is accepted. A pod network policy is refused as containment: it cannot block the pod’s own loopback, where the gateway and its sidecars listen. The playwright-containment CI job proves the proxy shape end to end (scripts/ci/playwright-containment-smoke.sh, deploy/compose/playwright-containment); docs/playwright-http-deployment.md is the shape, and says why @playwright/mcp’s --allowed-origins is not a boundary.

Git cloning is a separate outbound boundary for local stdio and direct CLI use. HTTP removes bb_clone from each session’s router and rejects direct calls before admission or filesystem/subprocess work; the same rule covers authenticated and local HTTP, and stateless and initialized requests. Locked by http_transport_tests::http_refuses_bb_clone_before_creating_a_destination, ::modern_tools_list_is_stateless_deterministic_and_cacheable and tool_surface_parity_tests::stdio_advertises_bb_clone_when_bitbucket_is_enabled. bb_clone binds the clone URL to the requested host and repository, pins Git’s transport, refuses redirects, and clears Git’s environment before spawning it (the row above), but Git’s own network stack does not pass through the HTTP enforcement adapter, so the clone produces no egress record. Its outcome record names the URL Git was handed and how Git ended (subprocesses), and the metadata fetch’s egress record shows where that URL came from (clone_tests::the_call_scope_records_where_git_was_pointed_and_how_it_ended, cli_audited_tests::bb_clone_journals_the_git_destination_on_its_outcome). BITBUCKET_CLONE_ROOT optionally confines the filesystem target; clone_tests also cover dot slugs, traversal and symlinks. The user’s ~/.gitconfig and ~/.ssh are honoured by design, so an operator-level rewrite there (url.<base>.insteadOf) is trusted. Process containment, network restrictions and disabling unneeded clone capability remain operator controls.

B9. Client to model provider through the inference path

Section titled “B9. Client to model provider through the inference path”

Runtime security plan §6 (PR 2) and §7 (PR 3). The gateway forwards model requests from the same client population as B1 to one configured provider, under a credential the client never holds. Under a schema 3 content rule prompts and completions are inspected in flight as decoded text; they transit the process and are never persisted.

Threat STRIDE Mitigation Locked by
T1 reaches a model without a token, with the wrong scope, or with a revoked token S, E The inference routes sit behind the same bearer validator, revocation list and generation as /mcp, requiring mcp:inference; the owner is the token’s, or Local only on a loopback bind with auth off inference_role_tests::inference_request_binds_to_the_token_owner, ::client_bearer_never_reaches_upstream (401 without a token)
T2 names another owner’s MCP session to borrow its context S, E An Mcp-Session-Id must be a live session bound to the same owner, else 404 Session not found; a matching id narrows the record only; nothing on this path creates or binds a session inference_role_tests::inference_request_with_a_session_id_of_another_owner_is_refused, ::inference_request_without_a_session_id_records_session_none
T2 reaches a model or provider policy does not grant, or a machine token uses a human’s allowance E Signed schema 3 models rules decide on the parsed model, the configured provider and the verified principal and kind; no rule, no section or a native Cedar document is default deny; the decision is journaled before anything is forwarded inference_role_tests::model_rule_denies_before_any_upstream_request, policy_engine_tests::schema_v3_models_rules_decide_inference_requests
The client’s bearer, API key or cookies are forwarded to the provider (token passthrough) I, S Only content-type, accept, anthropic-version and anthropic-beta are forwarded; the provider credential is a server setting added here inference_role_tests::client_bearer_never_reaches_upstream
T3 (the provider) returns an endless or stalled stream D Per-chunk idle bound (MCP_INFERENCE_STREAM_IDLE_TIMEOUT_SECONDS) ends the relay and journals idle_timeout with the bytes sent; the request body is capped (MCP_INFERENCE_MAX_REQUEST_BYTES) inference_role_tests::a_non_json_body_and_an_oversized_body_are_refused_before_admission; the relay’s Drop completion
A partial stream is retried and the prompt is sent twice R, I One wire attempt, never a replay: a stream that ends early is upstream_failed with attempt: 1 and the bytes the client got inference_role_tests::partial_stream_failure_is_one_failed_attempt_not_a_replay
The caller forges trace context to inject into the collector or to link to another tenant’s trace T, I traceparent is validated to its fixed grammar and kept verbatim only then; anything else is digested and the request starts its own trace, marked unlinked; tracestate is bounded and forwarded opaque inference_role_tests::traceparent_is_validated_kept_verbatim_and_garbage_is_digested, trace_context_invariants_tests, fuzz target trace_context
A prompt or completion lands in the journal, telemetry or a log I The journal records and the telemetry event have no field that can carry them; usage is read off a copy of the bytes by a bounded scanner; the provider credential is dropped after the header is set inference_role_tests::anthropic_stream_is_forwarded_unchanged_with_usage_recorded (journal text contains neither), sse_usage_invariants_tests
Telemetry backpressure delays or fails a request D The channel is bounded with try_send; overflow drops and counts, never waits inference_role_tests::telemetry_backpressure_drops_and_counts_without_delaying_the_request
An operator of one tenant reads another tenant’s data map through the admin API or the console I The data map is a tenant-bound report: reports/data-map takes a window only and fills the tenant from the caller’s verified identity, the generic usage route refuses a data_map query, and the console sends no tenant; class rows carry class ids and never a matched value admin_api_tests::data_map_is_bound_to_the_callers_tenant, unified_console_tests::data_map_is_tenant_scoped
Class events or spans carry a prompt, a value or a secret into the rollups or the trace store I ClassEvent and ClassRow have no field for content (class ids, vendor, tool, model); the telemetry event has none either, and typing its numbers for the trace store adds no field; the overlay’s /metrics is refused at the front end flow_policy_tests::class_rows_reach_the_data_map_from_both_paths, telemetry::otlp::tests, operations_tests::compose_front_end_never_forwards_metrics

| T2 (or a steered agent) sends a credential to the model in a prompt, spelled plainly or with JSON escapes, hidden in a field the gateway does not know, used as a tool-schema property name, or behind a data: prefix | I | A prompt content rule reviews every decoded string of the parsed body, member names included, before forwarding; a payload that is not base64 is text, under the same guard as write arguments; a finding denies with the provider-shaped envelope naming rule and detector, the finding is journaled before the intent, and the forwarded bytes are never the decoded form; undecoded base64 blocks are counted and refusable (binary_blocks: deny) | inference_guard_tests::secret_in_prompt_is_denied_before_upstream, ::json_escaped_secret_in_prompt_is_denied_and_forwarded_bytes_are_unchanged_when_allowed, ::binary_blocks_deny_refuses_an_image_prompt, ::secret_as_a_tool_schema_key_or_behind_a_data_prefix_is_denied, prompt_text_invariants_tests (and the prompt_text fuzz target) | | T3 (the provider) or a steered model emits a credential in a completion, split across deltas, JSON-escaped, or across a chunk boundary inside one event | I | A completion rule in stream mode reviews each completed event’s decoded deltas over a lookback window (the built-in detectors rescan it only when a streaming over-approximation of the secret patterns says the new text could complete a match), relays only the bytes of events already reviewed (an event in progress is held; an event past the 1 MiB event bound is never held and is refused under the rule’s on_error), and terminates on a finding (the upstream connection dropped, the client stream ended with an error, bytes_sent journaled); buffer mode and non-streamed bodies are held whole and released all or nothing | inference_guard_tests::secret_split_across_deltas_terminates_the_stream, ::secret_in_streamed_completion_terminates_and_journals_bytes_sent, ::buffer_mode_releases_nothing_on_finding, ::non_streamed_completion_with_a_secret_is_refused_whole, ::an_incomplete_event_is_held_until_it_is_inspected, ::buffered_completion_over_the_bound_is_refused_whatever_on_error_says, inference_guard_tests::an_event_past_the_event_bound_is_refused_not_relayed_uninspected, inference_guard_tests::a_clean_stream_whose_window_outgrows_the_bound_is_still_terminated, secret_detector_invariants_tests::the_gate_asks_for_inspection_when_a_split_token_completes, sse_scanner_invariants_tests (and the sse_scanner and secret_detector fuzz targets) | | An external content engine is slow, down, answers garbage, or refuses with a verdict and no findings | T, D | The webhook call is bounded (timeout, 64 KiB answer, the subject bound, 32 findings of bounded names) and goes through the shared HTTP facade; any failure is an inspection failure under the rule’s on_error, deny by default; the engine’s answer is applied under the rule’s own action and can neither escalate nor soften it; a verdict without findings is journaled as an engine finding and refuses on every path, the result boundary included; each call that reached the engine is on the egress record | inference_guard_tests::webhook_timeout_follows_rule_on_error, ::webhook_findings_refuse_under_the_rules_action, content_guard_tests::engine_verdict_without_findings_refuses_the_result | | Inspection itself persists a prompt or completion | I | Reviews hold findings (detector, class, count, offset digest) and never text; content_finding records are metadata by type; the relay’s buffers are dropped with the request | inference_guard_tests::sensitive_values_never_reach_storage_or_logs (journal file, process log and artifact directory) |

| T2 (or a steered agent) reads prod-classified data through /mcp, then sends it to a model, or to a vendor the data must not reach, from the same owner through a new session, a fresh token or a changed run id | I | Flow rules (plan §8): a call marks its owner with the upstream’s environment class before it runs and with the classes a result rule labelled; the owner’s marks are an authorization input on both paths, keyed by the verified owner and never by session, so a flows rule denies the vendor call or the prompt until the marks expire; the store is bounded and refuses a new owner rather than forgets (fail closed) | flow_policy_tests::prod_read_then_jira_write_is_denied_for_the_same_owner, ::prod_read_on_mcp_then_prompt_on_v1_is_denied_in_one_process, ::labelled_secret_in_a_result_taints_the_owner_for_prompts, ::new_session_for_the_same_owner_inherits_taint, ::changing_run_id_does_not_reset_taint, ::taint_expires_after_ttl, ::taint_table_full_refuses_new_owner, policy_engine_tests::flow_rule_denies_prompt_after_prod_read | | T2 runs many requests in parallel so each passes a nearly exhausted budget, or an OpenAI-compatible stream that never reports usage | D, E | Budgets reserve at admission under one lock, all or nothing, per owner or per tenant (decision 17); reported usage settles the reservation and an over-run refuses the next request; a rejection releases; a cancelled stream settles at the estimate; unknown_usage is per budget and deny by default; an exceeded budget denies or holds for a second person | run_budget_tests::concurrent_requests_cannot_over_reserve_a_token_budget, ::two_owners_cannot_over_reserve_one_tenant_budget, ::reported_usage_over_the_estimate_is_recorded_and_refuses_the_next_request, ::failed_reservation_on_a_second_budget_releases_the_first, ::cancelled_stream_settles_at_the_estimate, ::upstream_rejection_releases_the_reservation, ::openai_stream_without_usage_opt_in_is_refused_under_unknown_usage_deny, ::unknown_usage_ignore_charges_nothing_and_records_unknown, ::budget_hold_routes_to_approval, ::tool_call_budget_counts_calls_on_the_tool_path | | T2 re-issues a held prompt with a different body under the approval a second person gave, or keeps prompting after being revoked | E, S | A held inference request’s hold key binds a digest of the exact body (and the findings digest), so only the same bytes consume the approval; the proposal, consumption and application records join the intent as for a tool call; the inference routes consult the same revocation list as /mcp, and revocation leaves the owner’s taint in place | unified_call_approvals_tests::inference_hold_round_trip, revocation_tests::revoked_subject_is_refused_on_both_paths_and_keeps_its_taint, hold_key::tests::an_inference_key_binds_the_body_digest_and_the_findings |

Residual: detection is probabilistic and over decoded text only; base64 blocks are not decoded; in stream mode bytes relayed before a finding are gone; a prompt or completion rule cannot redact, and a completion cannot be held. Budgets count tokens and tool calls, not money; taint and budget state is per process (one enforcing replica) and starts afresh on restart; an estimate bounds reservations, not what a provider then reports. The _meta trace carrier on tool calls is not read yet, so tool spans start their own traces.

Threat STRIDE Mitigation Locked by
Local Vault import uses an administrator credential for vendor writes or leaves broad privileges behind I, E Explicit admin token/recovery-file opt-in; admin calls create a unique policy limited to planned KV data paths; imports use a separate in-memory, non-renewable writer with a ten-minute maximum TTL; revoke token and delete policy on exit; preserve runtime token and refuse redirects. Interruptions or failed cleanup can leave a policy; warnings name it and TTL bounds the token 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_failure_revokes_writer_and_deletes_policy, ::test_operator_token_file_and_cleanup_failures
A nested NinjaOne credential reference changes the destination or starts provider I/O during a call I, E Only password and totpSecret are discovered and expanded through the startup/refresh snapshot; URLs, prefixes and commands remain literal configuration; unresolved references refuse login before upstream I/O config_tests::ninjaone_nested_reference_discovery_is_limited_to_credential_fields, ninjaone_session_tests::nested_server_secret_references_resolve_before_login_and_rotate, ::unresolved_nested_reference_refuses_login_without_contacting_vendor
A reference cannot be resolved and the server starts with a blank credential S, I Unresolvable server settings refuse startup. An unavailable vendor credential degrades that vendor and refuses its calls before upstream I/O, while other vendors remain usable; it can recover without restart secret_source_tests::startup_fails_closed_on_unresolvable_server_settings, ::unavailable_vendor_credentials_refuse_calls_without_contacting_upstream, ::missing_vendor_document_allows_other_tools_and_recovers_without_restart, ::an_unresolved_reference_is_refused_by_name_never_sent_upstream
A source becomes unavailable mid-run D The last good value stays in force and health degrades; a sealed Vault or a provider outage never blanks a credential secret_source_tests::a_vanished_file_keeps_the_last_good_value_and_degrades_health, ::vault_rotation_lock_a_new_kv_version_changes_the_upstream_header_and_a_sealed_vault_degrades
A rotated credential keeps being used S Values are re-read every refresh interval; multi-part credentials rotate together from one document secret_source_tests::rotation_lock_a_rewritten_file_changes_the_upstream_header_without_a_restart, ::multi_part_credentials_rotate_together_from_one_document
A file:// reference reaches outside the intended file, or an oversized secret exhausts memory I, D A host part is refused; provider responses capped at 1 MiB secret_source_tests::file_adapter_refuses_a_host_part_and_a_huge_file
An unavailable OS store silently substitutes insecure storage, or a migration loses access to credentials I, T Identity owns explicit native stores; Linux requires encrypted Secret Service sessions. No mock/default-store fallback. Preserve service/account keys, read the legacy namespace only on absence, and delete both namespaces with failures propagated auth::keychain::backend::tests, crates/identity/tests/keychain_tests.rs, scripts/ci/keychain-compatibility.sh
A credential appears in a log line or diagnostic I Redacting writer on every log sink; secret values are never part of an error’s Display; native keychain errors use fixed categories rather than provider payloads src/logger.rs tests redacting_writer_masks_inline_token and the redacts_* set
A platform credentials endpoint is impersonated, or its proof leaks S, I The ECS task endpoint and the Container Apps identity endpoint are accepted only on loopback or link-local, never a routable host, and only when the configured mode names them; the Container Apps header value is secret material (file:// or literal, redacted from Debug), and a refused exchange is reported as a category with nothing of the platform’s message aws::tests::pod_identity_on_ecs_takes_the_relative_uri_and_sends_no_token, aws_contract_tests::pod_identity_on_ecs_reads_the_task_endpoint_without_authorization, secrets::azure::tests::managed_identity_takes_the_platform_endpoint_as_a_pair_and_keeps_the_header_secret, azure_contract_tests::managed_identity_on_container_apps_uses_the_platform_endpoint_and_its_header

This boundary is where evidence is made. The full argument about what the checkpoints prove, and against whom, is §6; the table covers the mechanisms.

Threat STRIDE Mitigation Locked by
A record inside the journal is edited (a deny turned into an allow, a subject swapped) T Every line extends chain = H(chain ‖ line); a checkpoint every N records, T seconds, and at shutdown carries the chain value, signed with the gateway’s Ed25519 key; the verifier recomputes from the bytes on disk audit_checkpoint_tests::the_verifier_catches_edits_forgeries_wrong_keys_and_a_truncated_tail, ::checkpoints_land_every_n_records_signed_and_exported_and_the_close_seals_the_tail, ::the_chain_runs_unbroken_across_a_reopen
The journal is cut short after a checkpoint T Every checkpoint is exported through CheckpointSink; a checkpoint the export holds past the journal’s last record is a truncation, and a checkpoint the journal holds that the export lacks is reported too, so a failed export cannot hide a later truncation audit_checkpoint_tests::the_verifier_catches_edits_forgeries_wrong_keys_and_a_truncated_tail, ::a_failed_export_is_reported_as_missing_and_duplicates_are_reported
The journal cannot be written D (chosen over T and R) Fail closed: refuse to start, refuse every call, poison after a write error; health answers 503 so the orchestrator stops routing to it audit_journal_tests::failed_journal_write_refuses_the_call_and_never_contacts_the_vendor, ::journal_configured_via_config_is_written_through_and_fails_startup_when_unusable, inbound_auth_tests::health_reports_an_unavailable_journal_as_503
An outcome append stalls and is abandoned, leaving an intent with no outcome R The caller stops waiting at the bound, but the write continues on a tracked task and shutdown drains it; an intent with no outcome is defined to mean “authorized; dispatch not evidenced”, and an auditor treats it as possibly dispatched audit_journal_tests::a_stalled_outcome_append_is_not_cancelled_by_the_bound, ::pending_outcome_appends_are_tracked_for_shutdown_to_drain
A second writer opens the same journal T Exclusive lock on a sidecar in the journal directory; a second writer refuses to start audit_journal_tests, docs/configuration.md MCP_AUDIT_JOURNAL_DIR
A torn trailing line after a crash is silently accepted or silently dropped T The reader reports a torn tail as such; recovery truncates it before verification resumes, and the forwarder waits for the next round rather than skipping src/audit/reader.rs (ReadError::TornTail), audit_forwarding_tests
The forwarded copy diverges from the journal T, R Records are forwarded byte for byte in sequence from a persisted cursor written only after adapter success, except RELP messages exceeding 128 KiB, which send an explicit sequence/byte-count/SHA-256 notice and retain the full event in the journal. The notice also requires acknowledgement; per-exchange timeouts and idle-closure probes prevent deterministic batch stalls. RELP requires a matching 200 response for every message; malformed, missing or rejected acknowledgements retain the batch cursor. RELP/HTTPS delivery is at least once subject to receiver queue durability; TCP syslog has its documented transport-only acknowledgement gap. seq plus origin deduplicates; the receiver may hold a line or explicit digest notice twice; every notice binds to a retained journal line audit_forwarding_tests (including RELP conformance and failed acknowledgement cursor/reconnect tests), relp_invariants_tests, siem_live_tests (CI siem job against syslog-ng over mutual TLS)
Forwarding over plaintext I syslog:// without TLS is refused; http:// accepted on loopback only audit_forwarding_tests
The audit signing key is readable by others E The server refuses a world-readable or group-writable key file at startup docs/configuration.md MCP_AUDIT_SIGNING_KEY; audit_checkpoint_tests::audit_keygen_and_verify_at_the_binary_boundary
Threat STRIDE Mitigation Locked by
T5 reads credentials from process memory or the keychain I Accepted. The gateway must hold credentials to do its job. Containment: distroless static image, non-root, read-only root filesystem, no shell; secret references rather than values in configuration, so a copied config discloses nothing Dockerfile (distroless/static, USER nonroot), deploy/k8s/README.md
T5 rewrites journal history and re-signs it with the audit key T, R Not mitigated on the host. Detectable only from a copy of an earlier checkpoint that the host cannot alter; see §6 —
T5 removes the checkpoint export along with the journal T Same as above: the export must leave the host —
T5 replaces the binary T, E Release archives and the container are Sigstore-signed and carry a CycloneDX SBOM; verification steps in the release runbook .github/workflows/release.yml, docs/release-operations-runbook.md
Admin CLI mutations bypass authenticated administration R, E mcp-devtools admin uses the authenticated, audited HTTP admin boundary; offline signing and file administration remain trusted operator actions admin_api_tests::admin_cli_uses_the_audited_http_boundary_and_json_errors
Direct vendor CLI calls bypass configured policy or audit R, E Jira, Confluence and Bitbucket CLI calls share admission and journaling when configured; they refuse execution where inbound identity is mandatory. An operator who independently holds vendor credentials can still call vendors outside this product cli_audited_tests::jira_get_is_journaled_as_intent_then_outcome_under_the_local_principal, ::a_policy_denial_refuses_the_cli_call_before_the_vendor_is_contacted, ::bb_and_conf_verbs_journal_under_their_own_tool_names, ::the_cli_refuses_to_run_where_inbound_identity_is_mandatory
Threat STRIDE Mitigation Locked by
A dependency with a known advisory or an incompatible licence is pulled in T Askama uses its default template directory without the unmaintained basic-toml config parser; patched cryptography, TLS and zstd dependencies; every dependency pinned exactly; cargo deny --locked check --deny warnings against the RustSec database on every commit; Cargo.lock checked in and verified; the self-rooted fuzz/Cargo.lock and eval/Cargo.lock resolve shared crates to root versions and have separate advisory gates; weekly Dependabot version updates with a seven-day cooldown; evaluation SDK/CLI/Python pins and wheel hashes, locked install and Python advisory audit; Linux userns provisioning uses a root-owned helper restricted to the trusted runner group, with host restrictions retained (record) .github/workflows/rust.yml quality job, .github/dependabot.yml, binary_tests::fuzz_lock_resolves_shared_crates_to_root_versions, binary_tests::eval_lock_resolves_shared_crates_to_root_versions
Vendored native code escapes registry advisory coverage T Linux keychain statically links libdbus from the locked libdbus-sys crate. Monitor its bundled C revision separately; rebuild for fixes. Preserve bundled license/source notices. Headless container/packages omit it docs/keychain-backends.md, licenses/libdbus-COPYING.txt, Cargo.lock; cargo deny covers registry metadata, not a full C-source audit
A platform-specific regression escapes routine CI T Linux and Windows build, lint and tests run on Reacher; routine macOS coverage requires repository variable MCP_CI_MACOS=true and available hosted billing. Release builds still require both macOS architectures .github/workflows/rust.yml, .github/workflows/release.yml; docs/release-operations-runbook.md CI runner availability
An unintended file (symlink target, secret, internal note) is compiled into the skill catalog I, T Explicit allowlist must equal the skills/ tree byte for byte; symlinks and special files refused; secret-shaped content and dotfiles refused skills_packaging_tests
Unsafe code introduces memory corruption T, E unsafe_code = "deny" crate-wide; clippy pedantic as errors; fuzz targets compiled in CI Cargo.toml [lints], fuzz/
A shared container cache certifies source for the wrong architecture T Source-content fingerprints are recorded separately for each musl target, forcing Cargo to notice changed sources even when COPY preserves old timestamps; failed builds never certify a snapshot tests/build_cache_test.py, scripts/release/with-source-cache.sh, Dockerfile
The release does not match the source T Tag must equal the Cargo version; archives, SBOM, and container signed in the release workflow; container built from a pinned Rust image onto a pinned distroless base .github/workflows/release.yml
A reviewer approves unseen policy bytes or reads another tenant’s proposal E, I Pending candidate reads require admin scope and the proposing tenant; registry digest checks bind displayed/downloaded bytes to the proposal. HTML is escaped and candidate responses are non-cacheable; approval independently rechecks signatures and generation freshness unified_console_tests::proposal_review_shows_stored_policy_and_downloads_exact_signed_bytes, unified_approvals_tests::pending_proposals_survive_a_restart_and_a_tampered_journal_is_refused
A deployment pulls a substituted or drifting WAF image T The WAF image is pinned by index digest, and every deployment path names the same pin operations_tests::every_deployment_path_pins_the_same_waf_image
A cloud template loosens a deployment constraint (a second writer, an ephemeral journal, a secret literal, an all-traffic egress rule, a mutable image tag) T, I, E deploy/aws/fargate.yaml and deploy/azure/main.bicep fix the constraints of docs/cloud-deployment-plan.md as values, not parameters, and are parsed offline on every PR; the checked-in ARM must be the build of the Bicep. What Container Apps cannot express (read-only root, a user, dropped capabilities) is recorded in deploy/azure/README.md rather than claimed operations_tests::aws_fargate_template_preserves_the_deployment_constraints, ::azure_container_apps_template_preserves_the_deployment_constraints, ::azure_arm_json_matches_the_bicep_parameters_and_pins; live: deploy/aws/smoke.sh, deploy/azure/smoke.sh (cloud-deploy-live.yml)
Unified capabilities silently activate or bypass prerequisites E Required approvals fail closed without a journal. The console requires its build feature and OIDC runtime configuration; proposal routes retain admin authentication capability_boundary_tests::required_approvals_cannot_silently_fall_back_without_a_journal, ::unified_proposals_preserve_admin_authentication_and_console_is_opt_in, unified_binary_tests::console_startup_refuses_missing_authentication_or_missing_build_feature

This section states exactly what the chain and the checkpoints prove, against which adversary, and what a deployment must add for the stronger claim. It resolves the documentation half of CF-23; the deployment examples must not claim more than this.

Every journal line, including checkpoint lines, extends a running SHA-256 over the complete line bytes. A checkpoint record carries the chain value over everything before it and an Ed25519 signature over a fixed rendering of its fields, made with MCP_AUDIT_SIGNING_KEY. Checkpoints are written every MCP_AUDIT_CHECKPOINT_RECORDS records (default 256), after MCP_AUDIT_CHECKPOINT_SECONDS (default 60) with unsealed records, and at every clean shutdown. Each checkpoint is also handed to the configured CheckpointSink, which by default writes one file per checkpoint to MCP_AUDIT_CHECKPOINT_EXPORT_DIR. Recovery and verification hash the same complete line bytes; a torn tail is truncated first. mcp-devtools audit verify --public-key … --checkpoints <dir> recomputes the chain, checks every signature, and compares the export with the journal in both directions (src/audit/verify.rs, Problem).

Adversary Can do Detected by Property
P1: can write the journal file, does not hold the audit key Edit, insert, or delete any record before a checkpoint ChainMismatch or CoverageMismatch at the next checkpoint; a re-signed checkpoint fails BadSignature Integrity of sealed history, verifiable by anyone holding the public key
P1 Cut the file after a checkpoint ExportBeyondJournal, if the export directory still holds the later checkpoint Truncation detection, only as strong as the export’s independence
P1 Alter the unsealed tail (records since the last checkpoint) Not detected until the next checkpoint seals them; the window is bounded by N records or T seconds and closed at every clean shutdown Bounded exposure, by design (plan §8: fsync batched per checkpoint, not per record)
P2: holds the audit signing key Rewrite history from sequence 1 and re-sign every checkpoint Only by comparing against an independently retained earlier checkpoint that the rewritten history no longer reproduces Detection depends entirely on external retention
P3: can write the export directory (in the same-volume example, anyone who can write the journal) Remove or rewrite the exported copies along with the journal Nothing on the host Same-volume export gives truncation detection against P1 only
P4: holds the audit key and can write every retained copy Anything Nothing Out of scope: this is the trust base

In short: the design protects against modification of the journal by someone who does not hold the signing key, when an auditor holds a trusted public key; and against truncation only when checkpoints are retained somewhere the gateway’s host cannot alter.

  • deploy/k8s/config.yaml exports checkpoints to /journal/checkpoints on the same volume as the journal. That gives the first property and not the second, and its README says so. It detects a truncated file, not a host whose disk was rewritten wholesale.
  • “Tamper-evident against a compromised host” requires copies of the checkpoints in a place the host cannot write: object storage with object lock, a WORM tier, or a SIEM that keeps its own copy. The C.3 forwarder ships every journal line, checkpoint lines included, byte for byte to the SIEM. A SIEM the gateway’s operator cannot alter therefore is an external retention of checkpoints, and a later rewrite by P2 is caught by comparing the SIEM’s copy of any earlier checkpoint against the journal’s current chain at that sequence. Until a deployment has such a copy, its notes must not make the stronger claim.
  • The verifier today compares the journal against a directory of exported checkpoint files. Verifying against a SIEM’s copy means exporting the checkpoint records for the range into that shape first; the forwarding runbook’s “Verifying the copy” section describes the byte-for-byte comparison. A verifier mode that reads the SIEM export directly would remove that step and is a reasonable follow-up, not a gap in the model.
  • The audit key is the trust anchor for P2. It is a file the gateway process must read, so a host compromise that reaches it moves the adversary from P1 to P2. Keeping the key out of the host entirely (an HSM or KMS signer behind the same signing port) is the design that removes P2; it is not implemented, and no deployment note should imply it.
  • Remote attestation of the gateway itself, the posture cMCP takes with a policy engine and trace claims inside a trusted execution environment, is the answer to “what does a verifier need to not trust the operator at all”. It is out of scope before Gate B; the posture this project takes is the customer’s own disk plus external retention, which is what §6.2 states and what the competitive register records.

In the order an operator should care about them. The first four are documented in detail as deviations in mcp-security-best-practices.md and are only summarised here.

  1. Local mode trusts every local process (MCP_AUTH_MODE=off over loopback HTTP). Enforced to be loopback-only; tracked as CF-44.

  2. No private-IP blocking on control-plane fetches. Operator network policy is the control.

  3. openid requested, no ID token validated. No residual risk identified; recorded for completeness.

  4. Console routes outside the transport’s Origin allowlist. The console has its own origin check in the control and console threat model.

  5. The unsealed tail. Records since the last checkpoint are integrity- protected only by the sequence, for at most N records or T seconds. Lowering both narrows the window at the cost of one write and sync per checkpoint.

  6. Same-host checkpoint export and the audit key on the host. §6. A deployment that wants evidence to survive a compromised host must ship the export or the forwarded journal off the host, and should treat the audit key file as the asset it is.

  7. Prompt injection through upstream content. The server does not inspect results and cannot know an agent has been steered. Its controls are on what the agent can then do: default-deny policy, RequestRisk derived from the request and never client-supplied, write freshness, per-principal rate limits, and, under a schema 3 content rule, the built-in secret detectors over a write’s decoded arguments. Detection is probabilistic (credential-shaped strings only, not split across two strings, not inside binary payloads); the request-side policy remains the provable boundary. An organisation that grants an agent broad write policy has accepted the remaining risk for that agent.

    Cross-call data flow is tracked by class, not by content. flows rules refuse a vendor call or a prompt once the verified owner has read an upstream classified by environment, or a result a content rule labelled, whatever session, client name or path comes next (adversarial_workflow_tests::prod_logs_read_in_one_session_cannot_leave_through_another, ::tool_result_secret_cannot_reach_a_model_even_encoded). Nothing records that unclassified, unlabelled content an agent is about to write came from an earlier read, so for that content per-call authorization cannot establish that a transfer is appropriate. An allowed read followed by an allowed write then discloses the content to the write’s destination, and the server will not prevent it. The mitigations are classifying sensitive upstreams, least-privilege write policy per agent (an agent that reads confidential projects gets no write rule to a less-trusted destination) and journal review: both calls are journaled under one principal, in order, with the read’s resource and the write’s destination, so a reviewer can see the transfer even though the server could not judge it. prompt_injection_tests::injected_instructions_cannot_make_a_denied_slack_write_reach_slack (and its _under_cedar twin) prove the denied half: the follow-up write is refused at admission and the vendor receives zero requests. ::an_allowed_read_followed_by_an_allowed_write_is_journaled_not_prevented documents the allowed half and the evidence it leaves, and ::injected_secret_in_tool_result_cannot_be_posted_by_follow_up_write shows a content rule stopping the allowed write when what it carries is a credential.

  8. Git destination and process authority. Prefix validation is not a host or repository allowlist; Git networking bypasses the Rust HTTP adapter. Clone-root confinement is optional. See B4 and the OWASP assessment.

  9. Per-replica rate limiter (CF-16): the token bucket is per process, so N replicas give N budgets.

  10. Independent use of vendor credentials. Configured direct vendor CLI calls use admission and audit, but a credential holder can call the vendor through another program. Credential distribution and shadow-server prevention are organizational controls.

  11. Denial of service by an authenticated caller within limits. The limits are the control; a single request that takes the process down is a vulnerability and in scope for SECURITY.md.

  12. The WAF does not inspect tool-call arguments or authored policy content. These are code-like by design (SQL, JQL, Cedar), so the WAF’s injection rules skip them on /mcp and on the administrator policy routes; what they may do is decided by policy and approvals, not by the WAF. The packages and the bare image ship without a WAF; a deployment that runs them directly must supply its own edge.

  • Compromise of the identity provider or of a vendor’s own systems.
  • Confidentiality of data the calling principal is legitimately allowed to read, once it has been returned to their agent runtime.
  • The security of the agent runtime and of the model it runs, including whether the agent follows instructions planted in vendor content. That is assessed by the client’s owner with an agent harness; a server test can only show what a maximally steered agent is then permitted to do.
  • Physical and hypervisor-level attacks on the host beyond what §6 says about evidence.

Re-review this page, and re-run the tests it names, whenever:

  • a new inbound transport or authentication mode is added (B1, B2);
  • a tool gains a parameter that can reach an outbound URL, which would end the structural argument in B4 and in the SSRF section of the best-practices map;
  • a new handle type, cache, or store is keyed by anything other than the principal (B1);
  • the journal record shape, the chain, or the checkpoint signing payload changes (§6): the verifier and this section must change together;
  • a CheckpointSink or forwarder implementation is added or a deployment example changes where the export is written (§6.3);
  • a cloud template under deploy/<cloud>/ is added or changes a value the constraints table of docs/cloud-deployment-plan.md fixes (B8);
  • the audit signing key moves off the host, which retires P2 in §6.2;
  • a gate is reached: the plan requires the threat model re-reviewed at each gate with every mitigation still mapped to a test.

A test named here that no longer exists is a finding against this document, not a reason to delete the row.

Checkpoint write poisoning is additionally locked through the public audit sink and real writer thread by audit::journal::tests::checkpoint_write_failure_poisons_the_sink_and_refuses_later_events. Atomic key installation is locked with two competing CLI processes by policy_signing_tests::concurrent_keygen_installs_exactly_one_complete_key.

Threat Control Locked by
Cross-subject/tenant task disclosure or mutation Owner predicate on reads and mutations; unknown/inaccessible IDs share an error; fresh bearer/scope/revocation middleware task_wire_tests, task_lifecycle_tests::ownership_unknown_ids_poll_limits_and_updates
Legacy capability fallback creates unsolicited durable work Protocol 2026-07-28 or later; rmcp validates modern request metadata and lifecycle capabilities; creation checks capability before committing; legacy lifecycle methods refused and initialize filtered task_wire_tests::stdio_creation_requires_each_requests_capability_and_preserves_initialize
Lost acknowledged handles, concurrent writers or corrupt lifecycle state WAL/FULL verified; commit before acknowledgement; dedicated worker and lifetime file lock; physical and semantic startup validation; fail closed on uncertain I/O/corruption; capacity errors preserve service tasks::sqlite::tests, task_lifecycle_tests
Unbounded polling, payloads or retention Bounded admission queue, row/result/database limits, per-task polling interval, finite TTL and batched sweep tasks::tests, task_lifecycle_tests
Task resource exhaustion or unintended CLI recovery CLI runtime never opens Tasks; only live rows count toward admission; lowered limits preserve reads; SQLite FULL returns capacity without disabling the worker; pristine first-boot files resume under the ownership lock task_lifecycle_tests::direct_cli_neither_locks_nor_recovers_the_server_task_store, tasks::slow_worker_tests, tasks::sqlite::tests
Background task bypasses admission, runs unbounded or leaks results Allow-listed tools only; full admission and a committed task before any vendor work; refusals and holds stay synchronous; concurrency bound with synchronous fallback; cancellation stops the call inside the audited scope; results owner-bound, oversize results spilled to owner-only artifacts or failed, never truncated background_task_tests
Task used as approval authority or saved execution context No tokens/arguments in contracts; updates ignored; linked cancellation journals a distinct owner-bound withdrawal first; pending status reconciles against the verified gate; retained terminal receipts grant no authority; no automatic execution unified_call_approvals_tests::approval_tasks, unified_jira_create_approval_tests::approval_tasks

The approval journal remains authoritative for execution permission. Task storage is not an audit journal. Local callers share the local owner. Private directory ancestors/ACLs, reliable sync and single-process ownership are deployment assumptions; active replicas and real power-loss certification are unsupported. Offline snapshots can lose newer handles on restore. A generic interrupted working task, including a background call, becomes a JSON-RPC failure, never a replayed action. Background results are vendor data at rest in the private task directory, protected by the same directory permissions and owner binding. Pending proposal-linked tasks use verified-journal reconciliation. See tasks deployment and recovery.

Pending approval-task recovery is lazy against the verified journal. Terminal results are historical receipts retained under owner/TTL checks, including when approvals are disabled. Their execution deadlines are explicit; a first observation of an expired unspent approval reports expiry. Terminal reads deliberately rely on the private database rather than revalidating audit evidence: stale or altered backups may misstate history, but can never authorize a re-issued call. Issuer-aware hold indexing prevents one identity provider’s approval being reused by another. A dedicated owner-checked call_cancellation releases a hold; administrator free text cannot do so. Partial-commit, cancellation-race, ownership and disabled-gate tests in approval_tasks and unified_call_approval_recovery_tests, plus tasks::sqlite::tests, lock these controls. Restore both stores at one offline maintenance boundary; external live writers remain unsupported.

The reference-runtime smoke extends the scripted evaluation with the real pinned SDK loop against a local provider script. It adds compatibility and isolation evidence, not live-model resistance evidence. The original runtime-owner responsibility in §8 remains: securing arbitrary runtimes is outside the gateway; this repository measures its configured integration. Linux namespace setup requires a root-owned, runner-group helper and an exact-path AppArmor permission; runtime qualification is pending (record). This is a trusted runner-account permission, not isolation between jobs sharing that account; no host-root privilege or global restriction disablement is added. D2–D5 remain open and no live-model baseline is recorded.