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.
1. The system in one paragraph
Section titled “1. The system in one paragraph”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.
2. Assets
Section titled “2. Assets”| # | 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 |
3. Trust boundaries
Section titled “3. Trust boundaries” 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 |
4. Attackers
Section titled “4. Attackers”| # | 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.
5. STRIDE by boundary
Section titled “5. STRIDE by boundary”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.
B1. Client to gateway
Section titled “B1. Client to gateway”| 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 |
B2. Gateway to identity provider
Section titled “B2. Gateway to identity provider”| 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 |
B3. Policy and revocation documents
Section titled “B3. Policy and revocation documents”| 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 |
B4. Gateway to vendors
Section titled “B4. Gateway to vendors”| 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.
B5. Gateway to secret sources
Section titled “B5. Gateway to secret sources”| 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 |
B6. Journal, checkpoints, and forwarding
Section titled “B6. Journal, checkpoints, and forwarding”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 |
B7. Host and operator
Section titled “B7. Host and operator”| 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 |
B8. Build and release
Section titled “B8. Build and release”| 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 |
6. The checkpoint threat model
Section titled “6. The checkpoint threat model”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.
6.1 Mechanism
Section titled “6.1 Mechanism”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).
6.2 What is proved, and against whom
Section titled “6.2 What is proved, and against whom”| 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.
6.3 What this means for a deployment
Section titled “6.3 What this means for a deployment”deploy/k8s/config.yamlexports checkpoints to/journal/checkpointson 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.
7. Accepted and residual risks
Section titled “7. Accepted and residual risks”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.
-
Local mode trusts every local process (
MCP_AUTH_MODE=offover loopback HTTP). Enforced to be loopback-only; tracked as CF-44. -
No private-IP blocking on control-plane fetches. Operator network policy is the control.
-
openidrequested, no ID token validated. No residual risk identified; recorded for completeness. -
Console routes outside the transport’s
Originallowlist. The console has its own origin check in the control and console threat model. -
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.
-
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.
-
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,
RequestRiskderived 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.
flowsrules 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_cedartwin) 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_preventeddocuments the allowed half and the evidence it leaves, and::injected_secret_in_tool_result_cannot_be_posted_by_follow_up_writeshows a content rule stopping the allowed write when what it carries is a credential. -
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.
-
Per-replica rate limiter (CF-16): the token bucket is per process, so N replicas give N budgets.
-
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.
-
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. -
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
/mcpand 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.
8. Out of scope
Section titled “8. Out of scope”- 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.
9. Keeping this document true
Section titled “9. Keeping this document true”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
CheckpointSinkor 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 ofdocs/cloud-deployment-plan.mdfixes (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.
References
Section titled “References”- Historical enterprise plan §3 (architecture), §8 (security and performance budgets), ADR-011 (audit durability).
- OWASP MCP Top 10 assessment.
- CF-23, the checkpoint threat model and an external retention boundary: huskercane/mcp-server-devtools#113. CF-11, WP 0.2 and 0.3: huskercane/mcp-server-devtools#101. This document: huskercane/mcp-server-devtools#40.
- MCP security best practices conformance map, audit reports, SIEM forwarding runbook, revocation runbook, release operations runbook, hexagonal boundaries.
- cMCP, Confidential MCP Gateway (AgenTrust): hardware-attested policy enforcement and signed trace claims; https://github.com/agentrust-io/cmcp. Cited for the operator-compromise posture this project deliberately does not take before Gate B.
- STRIDE: Shostack, Threat Modeling: Designing for Security, Wiley 2014.
- CVSS 4.0: https://www.first.org/cvss/v4.0/specification-document.
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.
Durable Tasks boundary (PRs 3–4)
Section titled “Durable Tasks boundary (PRs 3–4)”| 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.