| Version | Supported |
|---|---|
| 3.0.x | ✅ Active |
| 2.0.x – 2.7.x | ❌ No longer supported; upgrade to 3.0.x |
| 1.22.x | ✅ Security fixes only |
| < 1.22 | ❌ No longer supported |
3.0.0 is the release that implements the v13 trust boundary described below. Several things 2.7.x accepted are refused from 3.0.0 on, among them unapproved project configuration files, unapproved discovered packages and non-canonical auth URLs; the CHANGELOG's Upgrade notes for 3.0.0 list them all.
2.0.0 is a breaking release: GET /mcp is retired (405) and the
PMCP_KEEPALIVE_MAX_SECONDS lifetime cap is removed with no replacement by
design. See the CHANGELOG before upgrading. 1.22.x receives security fixes
only; it is the last 1.x line and is pinned to mcp 1.x, which no longer
receives upstream releases.
To upgrade from 2.x to 3.0, follow MIGRATING.md: it covers each breaking change, how to verify the fix, and how to roll back to 2.7.3.
PMCP is a local-first MCP gateway. Its default security posture assumes:
- Bind address:
127.0.0.1(loopback only). The HTTP port is not exposed to external networks unless you explicitly bind to0.0.0.0or place it behind a reverse proxy. - Trust boundary: processes running on the same host as PMCP are trusted. Remote clients
(via reverse proxy) are untrusted and must present a valid Bearer token when
shared-secretorresource-serverauth is configured. - TLS: PMCP does not terminate TLS. For any network exposure, terminate TLS at a reverse
proxy (nginx, Caddy) and proxy to
127.0.0.1:3344. See the README for example configs.
- Unauthenticated tool invocations via Bearer token guard on
/mcp - AS-issued access tokens in
resource-servermode via JWKS signature, issuer, expiry, not-before, and audience validation. The audience is bound to the operator-configured canonical resource URI (resource_server_audience, per RFC 8707) and is never derived from the request Host header. Signatures are only accepted for the operator-configured algorithm allowlist (defaultRS256/ES256); the token's ownalgheader is never trusted. The mode fails closed at startup without an issuer, JWKS URL, and audience, and the JWKS URL must behttpsand is rejected when its host is a non-public IP literal or the namelocalhost, when the host is not in canonical form (for example a trailing dot, non-ASCII characters, a legacy numeric address or an IPv6 zone id), or when the URL contains a backslash or a control character (see the DNS-name limitation below). JWKS is fetched asynchronously and cached so validation never blocks the event loop; an unreachable JWKS endpoint, or a key set with no usable keys, returns503while an invalid or wrong-audience token returns401. The fetch is bounded by a total timeout, and a failed refresh is shared across the requests waiting on it through a short backoff, so the last of them waits about one timeout, not one per request. Refreshes forced by an unknownkidare limited to one per cooldown window, so an unauthenticated caller cannot drive outbound JWKS fetches. The protected-resource metadataresourceis likewise operator-configured (resource_server_audience, else the configured metadata URL's origin) and never derived from the request Host header. - Timing oracle attacks on token comparison (
hmac.compare_digest) - Request floods via per-source-IP sliding-window rate limiting (
--rate-limit/PMCP_RATE_LIMIT) on/mcp - Oversized payloads causing OOM (
Content-Length > 10 MB → 413) - Hanging downstream tools consuming connections indefinitely (60 s request
timeout,
--request-timeout/PMCP_REQUEST_TIMEOUT) — applies to every/mcpPOST exceptsubscriptions/listen, which is a long-lived stream by design; see "Known limitations" below for what bounds it instead. - Unbounded long-lived streams on
/mcp(PMCP_MAX_LISTEN_STREAMS, default 64, bounds concurrentsubscriptions/listensubscriptions; retired GET's pre-session keep-alive concurrency cap is this guard's one-for-one predecessor) - Multiple gateway instances fighting over resources (fcntl singleton lock)
- Reconnect storms from crashing downstream servers (per-server reconnect flag)
- Unilateral credential relaxation: a manifest server's
requires_api_keycan only be relaxed by a variable the entry itself names inapi_key_optional_when— an operator's overlay can supply that variable's value, but cannot make a server's credential optional unless the manifest entry already declared it relaxable. A server also cannot name its own credential as its relaxer. Every unset, malformed, self-referencing, or placeholder (${VAR}) relaxer value fails closed and the credential stays required (#114). - Mutable CI dependencies: every GitHub Action this repository runs — in
.github/workflows/and in the local composite action under.github/actions/— is pinned to a full commit SHA with the release named in a trailing comment (owner/action@<sha> # vX.Y.Z). A tag or branch is mutable: whoever controls the action's repository controls what runs with the job's permissions, and the release workflow holdsid-token: writefor PyPI trusted publishing.scripts/check_workflows.pyfails CI on any remoteuses:that is not in that form, and on any change to the release workflow's exact action set; Dependabot maintains the pins, including a dedicated entry for the composite action's directory (#217).
- The auth-URL host check filters IP literals only: a public auth metadata
or JWKS URL is rejected when its host is a non-public IP literal — private,
CGNAT, link-local, loopback, multicast, site-local or unspecified, including
IPv4 addresses embedded in IPv6 literals (RFC 4291 mapped and compatible,
RFC 6052 NAT64 including the local-use
64:ff9b:1::/48, the IPv4-translated::ffff:0:0:0/96, RFC 3056 6to4, RFC 4380 Teredo, RFC 5214 ISATAP), and an IPv6 host must itself be public as well. Legacy numeric forms such as2852039166or0177.0.0.1are refused outright as non-canonical, whatever address they name. A DNS name is accepted without being resolved, so a name pointing at an internal address is not caught. PMCP does not resolve names deliberately: a lookup is TOCTOU-vulnerable and is not SSRF defence without connection-time IP pinning. What it does instead is stop claiming otherwise — a server-supplied URL is relayed unverified and presented as such, viaUrlElicitationInfo.url_verified,AuthMetadataInfo.verified_urls(one entry per URL field), andAuthChallengeInfo.resource_metadata_url_verified, all defaulting to unverified, with the caveat carried in thenext_stepstring an agent follows and inpmcp authoutput. Paths where PMCP fetches the URL itself fail closed and require a verified public literal, and a URL from a downstream server's payload may not be loopbackhttp://(#211). - No mTLS: clients are not authenticated by certificate; only Bearer token.
- No per-tool ACL on HTTP: any valid token can invoke any tool. Tool-level policy is enforced at the MCP layer, not the HTTP layer.
- Rate-limit source IPs may be shared: localhost clients usually share the same observed source IP, and reverse-proxied clients may share one bucket unless the proxy preserves distinct client IPs for PMCP.
/healthand/metricsare unauthenticated by design: load balancers and Prometheus scrapers typically cannot present Bearer tokens. Bearer auth for/mcpdoes not protect these endpoints. Do not expose them on a public interface without separate network-layer control (firewall rule, IP allowlist, or reverse-proxy policy).- Resource Server, not Authorization Server: PMCP can validate
Authorization Server issued access tokens in
resource-servermode, but it does not provide an Authorization Server, DCR, SSO, RBAC, billing, or a complete multi-tenant identity service. - Authorization discovery is diagnostic: PMCP can surface protected-resource, authorization-server, OIDC discovery, Client ID Metadata Document, scope, and URL-mode elicitation hints, but it does not store third-party OAuth refresh tokens.
- URL-mode elicitation is out of band: never paste OAuth codes, third-party
passwords, or provider refresh tokens into gateway tools.
gateway.auth_connectaccepts API-key credentials only for local env-store flows; URL-mode flows only accept an elicitation identifier and consent acknowledgement. - Redaction is best-effort defense in depth: PMCP redacts bearer tokens, API
keys, bare provider tokens (
sk-,ghp_,github_pat_), common secrets, URL userinfo, authorization codes, and auth-bearing query parameters from gateway outputs, status/doctor diagnostics, feedback payloads, and HTTP diagnostics. Redaction is applied across every task-emitting surface —gateway.invoke,gateway.tasks_result,gateway.tasks_list, andgateway.tasks_get, including taskstatus_messageand raw fields — and truncation summaries are built from post-redaction text. On top of those keyword rules, additive shape-based rules run over their output and can only replace more of it: vendor token shapes (AWS, Slack, Google, prefixed tokens such asglpat-), JWTs, PEM private-key blocks, high-entropy runs, values under a wider set of secret keys, URL userinfo, fragments and secret-keyed query values (judged after percent-decoding), and the text glued to the end of an existing marker (up to a delimiter; see the residuals). Every piece the keyword rules remove stays removed, markers are kept as written, and a JSON document stays valid. The residual classes (for example a tail after,,;,&or#, and a value under a whitespace-separated--passwordflag) are listed in the plan for #234. Treat all logs as operational data and avoid adding secrets to server names, tool names, or free-form descriptions. - Credential isolation is scoped, not identity-complete: user-scope env-store files are owned by the local OS account, project-scope env-store files are owned by the project directory, and remote header placeholders resolve from those stores plus process environment in non-tenant mode. Tenant remote-header mode reads tenant-scoped files derived from the resolved project root and must not read another tenant's file, but PMCP still does not provide cross-user identity or authorization isolation by itself. Project-scope and tenant files are read only through a walk confined to the project root, so a link that leaves the project contributes nothing. Values from such a file never enter pmcp's environment or a child's: only credential lookups see them, and never for a variable pmcp itself reads (#367).
- Tenant code-mode hosting keeps execution outside PMCP: the host contract
in
specs/tenant-code-mode-host-contract.mdtreats PMCP as the broker and the companion tenant server as the sandbox execution authority. The contract does not add PMCP-owned sandbox isolation, tenant auth, or durable execution logs. PMCP policy can allow or denytenant-code-modeandtenant-code-mode::*, apply output caps, and redact returned diagnostics, but production multi-tenant isolation still requires companion-server and deployment controls. - No audit log persistence: the per-call audit log (
tool_call tool=... ok=...) is written to stderr/stdout, and structuredgateway.health.audit_eventsare bounded in memory. The opt-in--audit-jsonlfile (PMCP_AUDIT_JSONL) records scoped-advisor tool calls andtools/callgate rejections, but has no rotation and no tamper evidence. There is no database. - Trace context is metadata, not identity: PMCP preserves accepted
traceparent,tracestate, andbaggagestrings only through explicit PMCP-owned fields or request metadata. Do not put bearer tokens, API keys, auth codes, user identifiers, or other secrets in trace baggage. - MCP task records are transient: task IDs are downstream server identifiers held in gateway memory for visibility and cancellation. pmcp keeps at most 100 per server and 1000 in total. A server past its own cap gives up its own records; only past the total does the server holding the most give one up. A forced disconnect cancels only the tasks still tracked when it starts. A remote server's task evicted before then keeps running after the disconnect. They are not durable audit records and do not provide cross-user authorization isolation on unauthenticated local transports.
- Two protocol eras are served on one endpoint, through the same policy
gate: the
MCP-Protocol-Version: 2026-07-28header plus aparams._metaenvelope route a request to the modern era instead of theinitialize-negotiated handshake era (2024-11-05–2025-11-25) — see README's protocol-negotiation section. Both eras dispatch through the same registered handlers, so the same policy, audit, and input-schema validation applies regardless of which era selected the request; the modern era is not a lower-trust bypass. Unsupported draft extensions beyond the six proxied operations,server/discover, andsubscriptions/listenremain out of scope until PMCP explicitly claims them. subscriptions/listenhas no absolute-lifetime cap, deliberately: it is exempt from therequest_timeoutwrapper that bounds every other/mcpPOST, because that wrapper silently truncated every subscription stream atrequest_timeoutseconds (60s by default) with no graceful close frame — the defect this exemption fixes, not a property worth preserving for a connection that is long-lived by design. What bounds exposure instead:PMCP_MAX_LISTEN_STREAMScaps concurrent subscriptions (default 64, same as the retired pre-session keep-alive's concurrency cap), the SDK's own per-stream event-backlog cap bounds a single subscription's memory, and/mcpauth applies tosubscriptions/listenexactly as to every other method wheneverauth_modeis configured. There is no configurable replacement for the retiredPMCP_KEEPALIVE_MAX_SECONDSabsolute-lifetime bound; an operator who needs one must enforce it at a reverse proxy or load balancer in front of PMCP.
- A discovered server is provisioned only after its package is resolved to a concrete registry entry, pinned to an exact version, and approved by identity rather than by the name its configuration gives it [C-01].
- Default-deny holds for a discovered server across
provision,connect,restartandupdate, while a manifest-backed server provisions unchanged [C-02]. - A
packages.denylistentry outranks an operator approval, so a package the operator trusted is still refused when policy denies it [C-03]. - An approved pin still refuses an unpinned argv, so an approved server whose command is a bare
npx -y pkgcannot re-resolve a floating version at spawn [C-04]. - An agent-supplied package-manager variable is part of a package's identity and is refused at registration and at
auth_connectfor a discovered server [C-05]. - The install argv is logged before the spawn with credentials redacted and the package identity left intact [C-06].
- A denylisted manifest package is refused at every spawning door, including one named only by its install argv or selected by an
npxoption [C-07].
- The project sources
.pmcp/manifest.yaml,.mcp.jsonand.mcp-gateway-policy.yamlare ignored until an operator approves them, so an unapproved source never replaces a shipped command and an unapproved project policy is never read [C-08]. - Approval is keyed to a source's content, so an approved source edited afterwards is refused again [C-09].
- Each source is read exactly once and the bytes that were gated are the bytes that are used, closing the window between the check and the use at every loader [C-10].
- A refused source hands back no bytes and fails closed on a store error or an unreadable file rather than raising [C-11].
- A refusal names the absolute-path
pmcp trust approvecommand that would grant it and reaches the operator as exactly one warning [C-12]. - An approved project policy can only narrow the operator's policy: it cannot widen what the operator allows and cannot drop the default redaction patterns [C-13].
- An unapproved overlay that adds a new server is not applied, and the added name is not treated as manifest-backed at
provisionorconnect[C-14]. - The approval store is content-keyed and user-scoped, and a store resident in any checkout (found by walking up to the filesystem root, through and above the home directory; neither the home directory itself nor a filesystem root is ever one) enclosing the served project, the directory pmcp was launched in (captured once), the file being approved, or the project its reader was bound to when built -- or in that directory itself when no checkout encloses it, unless it is the operator's own -- by file identity the home directory, one of its physical ancestors, or the default store's own real
.config/.config/pmcp, AND its spelling passes the same rules as HOME's -- is refused [C-15]. The home directory is the operator's only while HOME is a plain absolute path (no.or..component) the system resolves, no symlink met while resolving it -- in its spelling or inside any link's target, at any depth -- is held by a directory in a checkout (judged where the holder physically is; a Windows junction or other reparse point counts as a link), the system agrees where it lands, and no physical ancestor of the directory it names is a checkout (a filesystem or drive root never is; markers:.git,.mcp.json,package.json,pyproject.toml,.pmcp/manifest.yaml); HOME is never taken from a file pmcp loads, and while it is not the operator's -- judged again on every access, so a location pinned earlier in the process (the user store's) is refused too -- pmcp reads, writes, creates and removes none of the operator's home-scoped files (and the singleton lock file is never removed at all: a lock dies with the gateway holding it) (user config, user manifest overlay, base policy, guidance, user store, trust and approval stores, provisioned registry, registry cache, lock), and an approval recorded in such a store grants nothing [C-15]. Trust and approval decisions fail closed on such a HOME; the trust store is located as its writer locates it (the kernel's verdict on the whole path, then the final link chain), never by a lexical..collapse (see #374) [C-15]. A home directory that is itself a checkout (a dotfiles repository at HOME) is the operator's choice and is accepted; its trust and approval stores are then the operator's, so anything committed to or pulled into that repository is trusted as the operator's [C-15]. For teams that share a configuration repository: a store or lock folder linked into another repository is that repository's to write, and pmcp refuses it only while that repository is being judged -- served, or the directory pmcp was started in [C-15]. - A record marked denied is never treated as approved, and an absent record refuses [C-16].
- A checkout-set
PMCP_MANIFEST_PATH,PMCP_CONFIGorPMCP_POLICYis refused by provenance at both the startup and runtime doors, while an override the operator exported into their own shell still applies [C-17]. - Keys that a
.envload introduced, and credentials pmcp itself put into its environment, are stripped from every spawned server wherever the gateway runs; a name a project store merely lists strips nothing, so the operator's own exported value survives; and a server's own declared credential still resolves [C-18]. - A fresh operator with no approval store and no project files provisions, connects and restarts a manifest-backed server unchanged, consults no consent gate, and sees no new startup warning [C-19].
- Every refusal across the consent, provision and feedback gates carries a non-empty remedy that is runnable as printed, names only
pmcpverbs the CLI dispatches, and renders a path holding a space or shell metacharacters inert [C-20]. - Setting the startup policy carries an operator's prior content-keyed approval of the project
.mcp.jsonforward onto the exact bytes the edit writes, keyed on the opened descriptor's verified identity — the resolved key is accepted only when it names the same file (st_dev,st_ino) the descriptor holds open — and never records an approval for a file that was not already approved [C-28].
- Outbound feedback submission is off unless an operator both turns it on and passes an explicit per-call confirmation, and a gateway with no guidance configuration refuses to submit [C-21].
- Authorisation to submit is decided by a token's provenance rather than its presence, so an ambient
GITHUB_TOKENattempts no request and spawns nogh, and only an operator-supplied token is honoured [C-22]. - A checkout-supplied token or repository override is refused and a malformed or hostile override is rendered inert, so the destination stays the packaged repository [C-23].
- No egress door survives in the handler beyond the gated path, and a dispatched post is reported as submitted only when its creation is observed, never when the outcome is unconfirmed [C-24].
- Feedback provenance is tracked by a registry that records what the gateway wrote at runtime; that record, not a store's list of names, is what strips such a key from other servers' environments [C-25].
- The credential store is written atomically, through a temporary file that is synced and then renamed over the store, so an interrupted write leaves the previous store intact [C-35].
- Limitation. The approval store reserves a denied decision that nothing shipped writes: the CLI dispatches
approve,listandrevoke, and no verb records a denial [C-26]. - Limitation. An operator approval binds no integrity digest, because the CLI records it as none, so the pin is a name and a version and not the bytes the registry serves [C-27].
- Limitation. For a server name in neither the manifest nor the discovered table,
auth_connectadmits a credential-shaped override whose value is written to a gateway-managed store and inherited by no spawned server, though the gateway process itself holds it [C-29]. - Limitation. No per-socket timeout bounds a whole request, so a feedback worker abandoned by a cancelled handler can outlive it, although it is forbidden to send by then [C-30].
- Limitation. A credential store written and removed out of band, that this process never read, is invisible to every provenance source [C-31].
- Limitation. Secrets the operator exports into the shell that starts the gateway are still inherited by spawned servers, deliberately and out of scope [C-32].
- Limitation. A store resident in the served checkout is genuinely refused, but the refusal is raised per read and surfaces through the consent gate's not-approved message naming
pmcp trust approve, rather than as a distinct abort at startup [C-33]. - Limitation. The MCP registry identity (the README
mcp-namecomment and thenameinserver.json) still uses the pre-rename namespace, tracked as a follow-up rather than as a trust-boundary property [C-34]. - Limitation. The
pmcp trust approveverb now also refuses a store resident in the checkout containing the file being approved -- closing the case where approve wrote into a checkout-resident store thatserve --projectthen refused, and aligning nested and sibling checkout layouts of that shape through the shared enclosing-checkout walk -- leaving a narrow residual only because serve treats the served root itself as a boundary verbatim while approve keys on the checkout enclosing the approved path, so the two are not guaranteed identical for a served root that is not itself a marked checkout [C-36]. - Limitation. PMCP spawns a child process for every downstream server, and although a project configuration entry is gated by consent and a discovered package is bound to an approved identity, a server an operator approves still runs, so configure only servers you trust [C-37].
- Limitation. The carry-forward closes the byte-content and path-identity races — a target swapped or unlinked between the open and the resolve is refused because the resolved key is verified against the opened descriptor's inode, and on POSIX a symlinked project
.mcp.jsonis refused up front — leaving only the accepted, not-fail-safe residual that a swap racing the atomic write itself, or a symlinked final component whereO_NOFOLLOWis unavailable, could still bind a file whose bytes equal those written [C-38].
| Claim | Kind | Proof |
|---|---|---|
| C-01 | guarantee | tests/test_package_identity.py::test_an_exact_pin_resolves_to_that_version, tests/test_package_identity.py::test_an_invalid_package_name_is_refused_before_any_fetch, tests/test_package_approvals.py::test_an_approval_names_one_registry_name_and_resolved_version, tests/test_package_identity_gate.py::test_an_approved_pinned_discovered_server_still_connects, tests/test_trust_boundaries_e2e.py::test_s01_provision_refuses_an_arbitrary_package_under_an_allowlisted_name |
| C-02 | guarantee | tests/test_trust_boundaries_e2e.py::test_s01_provision_refuses_an_arbitrary_package_under_an_allowlisted_name, tests/test_trust_boundaries_e2e.py::test_s01_connect_server_refuses_the_same_registration, tests/test_trust_boundaries_e2e.py::test_s01_restart_server_refuses_the_same_registration, tests/test_trust_boundaries_e2e.py::test_s01_update_server_refuses_a_discovered_server_before_any_probe, tests/test_package_identity_gate.py::test_a_manifest_backed_server_provisions_unchanged |
| C-03 | guarantee | tests/test_trust_boundaries_composition.py::test_a_denylisted_package_outranks_an_operator_approval, tests/test_policy_package_identifiers.py::test_evaluate_package_policy_denylist_beats_allowlist, tests/test_policy_package_identifiers.py::test_a_project_denied_package_is_denied_even_when_the_user_allows_it |
| C-04 | guarantee | tests/test_trust_boundaries_composition.py::test_an_approved_pin_still_refuses_an_unpinned_argv, tests/test_package_identity_gate.py::test_a_configured_server_with_unpinned_argv_is_refused, tests/test_pkgid_manifest_npx_selectors.py::test_a_selected_or_positional_package_is_still_refused |
| C-05 | guarantee | tests/test_trust_boundaries_e2e.py::test_s01_a_package_manager_variable_is_refused_at_registration, tests/test_trust_boundaries_e2e.py::test_s01_auth_connect_refuses_a_package_manager_variable_for_a_discovered_server |
| C-06 | guarantee | tests/test_install_argv_logging.py::test_a_credential_bearing_argument_is_redacted_but_the_package_identity_is_not, tests/test_install_argv_logging.py::test_start_install_logs_rendered_argv_at_warning, tests/test_pkgid_spawn_logging.py::test_the_stdio_spawn_of_a_package_runner_logs_its_argv_before_spawning, tests/test_pkgid_spawn_logging.py::test_the_update_probe_redacts_a_credential_but_not_a_pinned_package |
| C-07 | guarantee | tests/test_pkgid_panel_fixes.py::test_a_denylisted_manifest_package_is_refused_by_every_spawning_door, tests/test_pkgid_panel_fixes.py::test_a_manifest_package_named_only_by_its_install_argv_is_refused, tests/test_pkgid_manifest_npx_selectors.py::test_a_denylisted_package_named_by_a_selector_is_refused |
| C-08 | guarantee | tests/test_trust_boundaries_e2e.py::test_s03_an_unapproved_overlay_does_not_replace_a_shipped_command, tests/test_trust_boundaries_e2e.py::test_s03_an_unapproved_project_mcp_json_is_not_applied, tests/test_trust_boundaries_e2e.py::test_s11_an_unapproved_project_policy_is_not_read_at_all, tests/test_project_consent_gate.py::test_an_unrecorded_path_is_refused, tests/test_project_source_consent_manifest.py::test_approved_overlay_is_applied, tests/test_project_source_consent_config.py::test_approved_project_mcp_json_is_applied |
| C-09 | guarantee | tests/test_trust_boundaries_e2e.py::test_s03_an_approved_source_edited_afterwards_is_refused_again, tests/test_trust_cli.py::test_approve_replaces_the_record_when_the_file_changes, tests/test_trust_cli.py::test_approve_records_the_file_and_makes_its_content_approved |
| C-10 | guarantee | tests/test_project_consent_gate.py::test_read_and_gate_opens_the_path_exactly_once, tests/test_trust_boundaries_e2e.py::test_s03_the_parsed_bytes_are_the_gated_bytes_at_every_loader |
| C-11 | guarantee | tests/test_project_consent_gate.py::test_read_and_gate_returns_none_bytes_when_refused, tests/test_project_consent_gate.py::test_a_store_error_is_refused_not_raised, tests/test_project_consent_gate.py::test_an_unreadable_source_is_refused_not_raised |
| C-12 | guarantee | tests/test_project_consent_gate.py::test_remediation_is_the_absolute_path_trust_approve_command, tests/test_project_consent_gate.py::test_log_refusal_emits_one_warning_naming_the_remediation, tests/test_refusal_remedies.py::test_a_consent_refusal_reaches_the_operator_as_one_warning_naming_its_remedy |
| C-13 | guarantee | tests/test_trust_boundaries_e2e.py::test_s11_an_approved_project_policy_cannot_widen_the_user_policy, tests/test_trust_boundaries_e2e.py::test_s11_an_approved_project_policy_cannot_drop_the_default_redaction_patterns, tests/test_project_source_consent_policy.py::test_project_redaction_patterns_extend_rather_than_replace_defaults |
| C-14 | guarantee | tests/test_trust_boundaries_composition.py::test_an_unapproved_overlay_that_adds_a_server_is_not_applied, tests/test_trust_boundaries_composition.py::test_an_added_server_is_not_manifest_backed_at_provision, tests/test_trust_boundaries_composition.py::test_an_added_server_is_not_manifest_backed_at_connect |
| C-15 | guarantee | tests/test_trust_store.py::test_is_approved_judges_supplied_bytes_not_the_path, tests/test_trust_store.py::test_a_store_directory_pmcp_creates_is_not_group_or_world_accessible, tests/test_trust_boundaries_composition.py::test_a_trust_store_shipped_inside_the_checkout_is_refused, tests/test_trust_boundaries_composition.py::test_a_checkout_resident_store_is_refused_through_a_symlink, tests/test_trust_boundaries_composition.py::test_an_approval_in_a_checkout_resident_store_grants_nothing, tests/test_trust_store_residency_root.py::test_serving_a_project_refuses_its_checkout_resident_store_from_elsewhere, tests/test_trust_store_residency_root.py::test_serving_one_project_still_refuses_a_store_resident_in_the_launch_checkout, tests/test_trust_store_residency_root.py::test_serving_still_loads_an_operator_store_outside_the_checkout, tests/test_trust_store_residency_root.py::test_serving_a_subdirectory_refuses_a_store_resident_in_the_enclosing_checkout, tests/test_trust_store_residency_root.py::test_a_bare_serve_from_a_subdirectory_refuses_a_store_in_the_enclosing_checkout |
| C-16 | guarantee | tests/test_trust_store.py::test_a_denied_record_is_not_approved, tests/test_trust_cli.py::test_revoke_removes_the_record_so_the_content_is_no_longer_approved |
| C-17 | guarantee | tests/test_env_overlay_provenance.py::test_predicate_true_for_a_shell_exported_var, tests/test_env_overlay_provenance.py::test_predicate_false_when_dotenv_sourced, tests/test_env_overlay_provenance.py::test_predicate_false_when_pmcp_introduced, tests/test_env_overlay_provenance.py::test_startup_door_does_not_apply_the_injected_overlay, tests/test_env_overlay_provenance.py::test_runtime_door_does_not_apply_the_injected_overlay, tests/test_env_overlay_provenance.py::test_operator_exported_manifest_path_still_applies, tests/test_env_overlay_provenance.py::test_operator_exported_config_still_a_source, tests/test_env_overlay_provenance.py::test_operator_exported_policy_still_adopted |
| C-18 | guarantee | tests/test_env_leak_229.py::test_a_startup_env_secret_never_reaches_a_spawned_server, tests/test_env_leak_229.py::test_only_the_declared_key_is_injected, tests/test_env_leak_229.py::test_the_servers_own_credential_still_resolves, tests/test_install_child_env_project_root.py::test_an_install_spawn_does_not_inherit_a_project_scoped_credential_when_the_cwd_differs, tests/test_install_child_env_project_root.py::test_the_production_install_path_passes_the_gateways_project_root, tests/test_install_child_env_project_root.py::test_a_project_scoped_credential_is_stripped_when_the_cwd_is_the_project_root, tests/test_install_child_env_project_root.py::test_the_strip_is_by_provenance_whatever_the_root_or_working_directory, tests/test_spawn_env_sites.py::test_grok_r10_f001_a_checkout_cannot_strip_operator_ambient, tests/test_install_child_env_project_root.py::test_no_production_call_site_omits_the_project_root, tests/test_trust_boundaries_composition.py::test_an_install_spawn_strips_the_credentials_pmcp_stores_hold, tests/test_trust_boundaries_composition.py::test_the_install_spawn_strip_uses_the_gateways_project_root_not_the_working_directory |
| C-19 | guarantee | tests/test_fresh_operator_baseline.py::test_a_fresh_home_holds_no_trust_store_and_no_package_approvals, tests/test_fresh_operator_baseline.py::test_a_manifest_backed_server_provisions_with_no_trust_store, tests/test_fresh_operator_baseline.py::test_a_manifest_backed_server_connects_with_no_trust_store, tests/test_fresh_operator_baseline.py::test_a_manifest_backed_server_restarts_with_no_trust_store, tests/test_fresh_operator_baseline.py::test_no_project_file_means_no_consent_gate_is_consulted, tests/test_fresh_operator_baseline.py::test_a_fresh_operator_sees_no_new_startup_warning, tests/test_fresh_operator_baseline.py::test_every_shipped_manifest_entry_is_determinable_without_a_denylist |
| C-20 | guarantee | tests/test_refusal_remedies.py::test_every_refusal_carries_a_non_empty_remedy, tests/test_refusal_remedies.py::test_every_pmcp_command_in_a_remedy_names_a_verb_the_cli_dispatches, tests/test_refusal_remedies.py::test_every_remedy_is_runnable_as_printed, tests/test_refusal_remedies.py::test_a_path_containing_a_space_yields_a_runnable_approve_command, tests/test_refusal_remedies.py::test_a_path_carrying_shell_metacharacters_is_rendered_inert, tests/test_refusal_remedies.py::test_every_consent_refusal_reason_is_driven_by_this_audit, tests/test_refusal_remedies.py::test_every_provision_refusal_reason_is_driven_by_this_audit, tests/test_refusal_remedies.py::test_every_feedback_refusal_reason_is_driven_by_this_audit |
| C-21 | guarantee | tests/test_feedback_submission_flag.py::test_feedback_submission_defaults_to_off, tests/test_feedback_submission_flag.py::test_the_cli_verb_persists_the_submission_flag, tests/test_feedback_egress.py::test_a_gateway_with_no_guidance_config_refuses_to_submit, tests/test_refusal_remedies.py::test_a_feedback_refusal_reaches_the_tool_output_with_its_remedy |
| C-22 | guarantee | tests/test_trust_boundaries_e2e.py::test_s04_an_ambient_github_token_submits_nothing_and_spawns_no_gh, tests/test_feedback_egress.py::test_an_ambient_github_token_attempts_no_request, tests/test_feedback_egress_gate.py::test_a_checkout_sourced_token_is_refused, tests/test_egress_panel_fixes.py::test_a_missing_store_still_allows_an_operator_exported_token |
| C-23 | guarantee | tests/test_trust_boundaries_e2e.py::test_s04_a_checkout_supplied_token_or_repository_is_refused, tests/test_feedback_egress.py::test_a_checkout_env_file_cannot_redirect_the_destination, tests/test_feedback_egress.py::test_a_checkout_env_file_cannot_supply_the_token, tests/test_feedback_egress.py::test_a_hostile_repository_override_is_rendered_inert_in_the_refusal, tests/test_feedback_egress_gate.py::test_a_checkout_sourced_repository_override_is_refused, tests/test_feedback_egress_gate.py::test_a_malformed_repository_override_is_refused |
| C-24 | guarantee | tests/test_trust_boundaries_e2e.py::test_s04_no_egress_door_remains_in_the_handler, tests/test_feedback_egress.py::test_a_dispatched_but_unconfirmed_post_is_not_reported_as_submitted, tests/test_feedback_egress.py::test_a_creation_observed_before_the_timeout_is_reported_as_submitted, tests/test_feedback_egress.py::test_a_failed_submission_opens_no_second_door, tests/test_feedback_egress_gate.py::test_a_dispatched_post_with_no_response_is_reported_as_unconfirmed |
| C-25 | guarantee | tests/test_feedback_provenance.py::test_a_key_pmcp_wrote_at_runtime_is_recorded, tests/test_feedback_provenance.py::test_a_key_pmcp_put_into_its_environment_is_stripped_from_children, tests/test_feedback_provenance.py::test_the_strict_lookup_raises_where_the_lenient_one_swallows |
| C-26 | limitation | — |
| C-27 | limitation | characterizes: tests/test_package_identity.py::test_a_missing_integrity_is_none_not_a_fabrication, tests/test_package_approvals.py::test_an_integrity_that_contradicts_the_record_is_not_approved |
| C-28 | guarantee | tests/test_startup_policy_reapproval.py::test_happy_path_carries_the_approval_forward_and_the_next_load_applies_it, tests/test_startup_policy_reapproval.py::test_an_unapproved_file_is_not_auto_approved, tests/test_startup_policy_reapproval.py::test_a_post_write_byte_substitution_is_not_approved |
| C-29 | limitation | — |
| C-30 | limitation | characterizes: tests/test_egress_panel_fixes.py::test_a_cancelled_handler_abandons_the_claim_so_the_worker_never_posts, tests/test_egress_panel_fixes.py::test_a_cancelled_handler_re_raises_rather_than_answering |
| C-31 | limitation | — |
| C-32 | limitation | characterizes: tests/test_env_leak_229.py::test_a_shell_provided_variable_sharing_a_name_is_not_stripped, tests/test_env_overlay_provenance.py::test_predicate_true_for_a_shell_exported_var |
| C-33 | limitation | — |
| C-34 | limitation | — |
| C-35 | guarantee | tests/test_env_store_atomic_write.py::test_an_interrupted_write_leaves_the_existing_store_intact, tests/test_env_store_atomic_write.py::test_a_successful_write_replaces_content_at_0600_with_no_temp_left |
| C-36 | limitation | characterizes: tests/test_trust_store_residency_root.py::test_trust_approve_verb_inside_a_checkout_uses_cwd, tests/test_trust_store_residency_root.py::test_trust_approve_verb_still_refuses_a_checkout_resident_store, tests/test_trust_store_residency_root.py::test_trust_approve_from_outside_refuses_a_store_in_the_approved_paths_checkout, tests/test_trust_store_residency_root.py::test_trust_approve_from_outside_with_a_store_outside_still_approves |
| C-37 | limitation | — |
| C-38 | limitation | characterizes: tests/test_startup_policy_reapproval.py::test_a_post_write_symlink_swap_does_not_transfer_and_a_symlink_is_refused, tests/test_startup_policy_reapproval.py::test_a_capture_vs_resolve_swap_does_not_transfer_approval, tests/test_startup_policy_reapproval.py::test_an_unlink_after_open_is_refused_not_resurrected |
Please do not open a public GitHub issue for security vulnerabilities.
Report via GitHub private security advisory: https://github.com/Consiliency/pmcp/security/advisories/new
Include:
- A description of the vulnerability and its impact
- Steps to reproduce (proof-of-concept if possible)
- Affected versions
- Any suggested mitigation
Response timeline:
- Acknowledgment within 7 days
- Fix or mitigation plan within 30 days for critical/high severity
- Coordinated disclosure after patch is available
Before exposing PMCP beyond localhost:
- Set
PMCP_AUTH_TOKEN(do not use--auth-token; token visible inps aux) - Terminate TLS at your reverse proxy; proxy to
127.0.0.1:3344 - Bind to loopback (
--host 127.0.0.1, the default) - Set
--rate-limitappropriate for your traffic (e.g.60for 1 req/sec per observed source IP) - Firewall
/healthand/metricsto internal networks only - Run as a non-root user (Docker image already uses
appuser) - Review downstream MCP server configs — only trust servers you control
- For
tenant-code-mode, keep${TENANT_CODE_MODE_MCP_TOKEN}and${TENANT_CODE_MODE_TENANT_ID}in env stores or process environment, not config files or logs - For hosted tenant runs, use
gateway.tasks_cancelwith downstream task IDs and keep durable logs, artifacts, artifact retention, tenant auth, SSO/RBAC, and billing outside PMCP