passmcp — Security Model & Assurance Case¶
Status: Living document. Last full review: 2026-09-11.
Owner: Sebastien Rousseau (@sebastienrousseau).
Scope: the passmcp binary, the library packages it is built on, the
release pipeline that produces its artefacts, and the on-disk state it
keeps (the token store and any report directory).
This document is passmcp's assurance case: a structured argument that the project is secure to a stated level, with the evidence that backs each claim. It is deliberately narrower and more explicit than a marketing-style "security policy". Reviewers, packagers, and downstream users should be able to read this document and understand what passmcp protects, what it does not protect, what could go wrong, and what compensating controls exist.
1. What passmcp is¶
passmcp is a Go command-line tool that connects to a remote Model Context Protocol server the way an agent would — with credentials an operator supplies — and reports, step by step, what it observed. It makes real requests: OAuth discovery and token exchange, the MCP handshake, catalog listing, tool invocations, resource reads, prompt renders, repeated and parallel calls.
It is:
- A client of the server under test. It never listens on the network
except for the loopback redirect that
passmcp loginopens for the duration of one authorization, at most five minutes, answering only the redirect path. - A holder of credentials for the duration of a run, and of a user
token between runs when
passmcp loginwas used. - A writer of reports that describe a server in detail and can, with
--capture-bodies, include what it returned.
2. Trust boundaries¶
passmcp operates across four trust boundaries:
| # | Boundary | Direction | What crosses it |
|---|---|---|---|
| 1 | Operator's shell → passmcp process |
in | CLI flags, PASSMCP_* environment, the config file, the token store |
| 2 | passmcp → authorization server |
out | metadata fetches, registration, token requests carrying client secrets |
| 3 | passmcp → MCP server |
out/in | JSON-RPC over HTTPS with a bearer token; every byte of the reply is untrusted |
| 4 | passmcp → report directory and stdout |
out | findings, catalog, telemetry, optionally captured bodies |
The threat model is about limiting blast radius: passmcp runs with credentials the operator chose against servers the operator chose. It cannot make a server safe; it can make sure that running it does not leak the credential, does not damage the server's data, and does not let the server's output reach the report unbounded.
3. Security properties (claims)¶
We claim the following properties. Each is followed by the evidence.
C1. No credential reaches the report, the telemetry, or the logs¶
Argument. Every HTTP exchange passes through one recorder
(internal/telemetry), and every operator-supplied secret is registered
with its redactor before the client is built. The redactor masks by
header name, by URL and form parameter name, and structurally inside JSON
bodies, where it also registers the values it finds — so a token issued in
the middle of a run is masked in every later event. Content types are not
trusted; a body that starts with { is treated as JSON. The stderr
banner and the report's credential summary print the source of each
credential (flag, environment variable, profile, store), never its value.
Evidence.
probe.RuncallsRecorder.Redactor.Addfor every value increds.Credentials.Secrets()before constructing the client.internal/telemetry/recorder_test.goasserts thatAuthorization,X-Api-KeyandSet-Cookieare masked, that a registered secret in a request body is masked, thatcode=in a query string is masked, and that the HAR export contains no secret.TestFullRunClientCredentialsininternal/probeserialises every recorded event after a full nine-phase run and fails if the operator's client secret, the dynamically registered client secret or any issued access token appears.internal/creds/creds_test.goassertsDescribe()leaks no secret.- The token store is written
0600in a0700directory, atomically (internal/creds/store.go); on Windows it is written with an access-control list that grants only the current user (internal/creds/perm_windows.go).
C2. Unauthenticated probes are actually unauthenticated¶
Argument. The client's transport chain adds the operator's bearer token, fixed headers and basic credentials to every request. Two checks — first contact, and the invalid-token rejection probe — only mean something if they arrive with none of those. A second transport to the same endpoint, carrying nothing but the trace header, exists for them.
Evidence. Session.Bare in internal/probe/probe.go;
TestServerAcceptingGarbageTokenIsCritical fails a server that answers
200 to a made-up token even when the operator supplied a valid one.
ADR-0001.
C3. passmcp does not mutate the server under test unless told to¶
Argument. diagnostics.Policy decides what may be invoked. By
default only tools declaring readOnlyHint: true run. A tool without
annotations is destructive under the MCP specification's defaults and is
skipped. --allow-mutations unlocks tools that declare
destructiveHint: false; --allow-destructive unlocks everything and is
documented as dangerous. Resources are read and prompts rendered, both
of which the specification defines as non-mutating. Requests are throttled
to --rps (default 2) including the parallel burst, unless --allow-load
is passed, and neither the rate nor the burst can be raised without
bound: --rps above 100 or --concurrency above 64 is refused before a
run starts (engine.ValidatePace).
Evidence. TestPolicyDefaults in diagnostics; the fake server in
internal/probe/fake_test.go panics if its unannotated delete_all tool
is called, and every probe test runs under the default policy.
ADR-0004.
There is no adversarial mode to enable: no flag or command sends exploit-shaped input to a server's tools (ADR-0008). What passmcp sends is protocol conformance probes, calls permitted by the policy above, and one request with an invalid token.
C4. Server output cannot flood or corrupt the report¶
Argument. Every string the server chooses — tool names, descriptions,
error text, content — is bounded before it enters a finding, and response
bodies are capped at 1 MiB on the raw transport and at BodyCap (64 KiB)
in captured telemetry. Findings never embed server text unescaped into
the Markdown renderer's table cells. Every rendering meant for a person
(the text and Markdown reports, the live TUI and tool selector, the
human diagnostic format, and the text output of call, read,
prompt and watch) removes ANSI escape sequences and C0 and C1
control characters from server text before it reaches a terminal, so a
tool name or an error string cannot move the cursor, retitle the window
or write to the clipboard. The machine renderings (JSON, NDJSON, SARIF,
HAR, structured diagnostics) keep the server's text exactly: their
encoders escape control characters, and a consumer of them is owed what
the server sent.
Evidence. truncate in internal/probe; io.LimitReader in
transport.Streamable.Do; Recorder.BodyCap; esc in
internal/report/render_md.go; internal/termsafe, with
TestTextAndMarkdownNeutraliseTerminalSequences,
TestRunViewNeutralisesServerText, TestSelectorNeutralisesToolNames,
TestHumanFormatNeutralisesTerminalSequences and
TestTextOutputsNeutraliseServerText.
C5. The release artefacts you download are the artefacts we built¶
Argument. Every release is built by a GitHub-hosted runner from a semantic-version tag, signed keylessly with cosign (Sigstore), and accompanied by a SLSA build provenance attestation and a CycloneDX SBOM, all published together to the same GitHub Release.
Evidence. .github/workflows/release.yml and .goreleaser.yaml;
every action is SHA-pinned per OpenSSF Scorecard Pinned-Dependencies.
C6. The parsing boundaries are fuzzed¶
Argument. Every byte of a WWW-Authenticate header, an SSE stream or a
tool's JSON Schema comes from the server under test, and so do the tool
names and arguments passmcp encodes into MCP parameter headers. The
parsers for each, and that encoder, are fuzz targets run on every push.
Evidence. FuzzParseWWWAuthenticate (auth), FuzzReadSSE and
FuzzHeaderValue (transport), FuzzValidate and FuzzArguments
(diagnostics),
FuzzSchemaValid (internal/probe), FuzzParse
(internal/clientconf) and FuzzString (internal/termsafe, the
terminal-sequence filter every person-facing rendering of server text
passes through), driven by scripts/fuzz.sh and
.github/workflows/fuzz.yml.
4. Threats considered and out of scope¶
In scope¶
- Credential in a report shared with a third party: mitigated by C1.
--capture-bodiesis off by default because a server's own response content is not passmcp's to redact. - A server that accepts any token looking healthy: mitigated by C2; reported as a critical finding.
- Running passmcp against production and deleting something: mitigated by C3.
- Hostile server output (oversized bodies, injected Markdown, control characters in tool names): mitigated by C4.
- Supply chain against release: mitigated by SHA-pinned actions, cosign and SLSA (C5).
- Dependency compromise: mitigated by Dependabot on
go.mod,govulncheckin CI, library packages that import only the standard library, and every direct dependency listed inSBOM.mdand checked againstgo.modin CI;go.sumlocks transitive hashes.
Out of scope¶
- A server that logs the credentials passmcp sends. Boundary 2 and 3 are the operator's choice; passmcp cannot prevent the far side from recording what it was sent.
- Compromise of the maintainer's laptop or GitHub account. The maintainer's account is the root of trust; if it is compromised, a malicious release could be signed and shipped. Sigstore's Rekor transparency log makes such a release publicly auditable after the fact but does not prevent it. Users concerned about this scenario should pin to a specific release tag and checksum.
- A malicious config file or token store on the operator's machine. Both are read with the operator's own permissions; an attacker who can write them can already run commands as the operator.
- Denial of service against the server under test. The throttle and
the bounded burst are there to avoid it by accident;
--allow-loadremoves the throttle on purpose and is the operator's decision. - Rate-limit exhaustion of the operator's own quota on a shared authorization server.
- Injection and request-forgery testing of the server's tools. passmcp contains no adversarial probes and will not (ADR-0008). That testing belongs to dedicated security tooling under a scoped, authorised engagement.
5. Assumptions¶
- The credentials the operator supplies are scoped to the server under
test. passmcp requests the scope the server's challenge names, or what
--scopesays, and nothing broader. - The operator's clock is roughly correct (needed for TLS validation and token expiry arithmetic).
- The operator runs a supported OS: recent Linux, macOS ≥ 14, or Windows 11.
- A report directory is treated with the same care as the server's own
responses when
--capture-bodiesis on.
6. Compensating controls (bus factor + solo maintainer)¶
passmcp has a single maintainer. This is a real risk to sustained security response. Mitigations:
- Public assurance case (this doc): a successor maintainer or reviewing packager can pick up where the current maintainer left off without back-channel context.
- Documented signing key location:
MAINTAINERS.mdrecords where the release-signing key is published; a successor can publish a new key and users can reason about the transition. - Documented external services:
MAINTAINERS.mdcatalogues every external account (GitHub repository, ghcr.io, Homebrew tap, AUR) so continuity is auditable rather than tribal. - Fork-and-continue is explicit: GPL-3.0 licensing + the six-month
unresponsive-maintainer clause in
GOVERNANCE.mdnormalise the community-fork path.
7. Secure design principles¶
The claims in section 3 hold because the design follows the principles Saltzer and Schroeder set out in The Protection of Information in Computer Systems (1975). This section is the argument that each one was applied, with the code that applies it and the test that would fail if it stopped being true.
Economy of mechanism¶
Each security decision is made in one small place, so there is one place to review.
- One recorder. Every HTTP exchange passes through
telemetry.Recorder.Wrap, and redaction happens there and nowhere else (ADR-0003). - One invocation policy.
diagnostics.Policy.Decideis a single function of about twenty lines that answers "may this tool run". - One engine for three surfaces. The CLI, the TUI and the web shell
build a
RunSpecfor the sameinternal/engine;TestEveryRunFlagHasASpecField(cmd/parity_test.go) fails when a flag configures a run outside it. - Few moving parts on the wire. The library packages (
passmcp,auth,transport,diagnostics) import only the standard library. The JSON Schema validator is hand-rolled rather than a dependency, and fuzzed (FuzzValidate,FuzzSchemaValid).
Fail-safe defaults¶
Access is decided by permission, not exclusion: the zero value of every guard is the strict one, and an unknown case is refused.
- Tools. A zero
diagnostics.Policyinvokes only tools that declarereadOnlyHint: true; a tool without annotations is destructive, as the MCP specification's default says (TestPolicyDefaults, ADR-0004). - Discovered URLs. A zero
auth.URLPolicyrequires HTTPS and a public address; the overrides are opt-in flags (TestURLPolicyRejectsPlaintextAndInternalHosts,TestURLPolicyEscapeHatches). - Credentials. A nil
auth.OriginSetallows no origin at all. - TLS. A failed handshake is a critical finding that stops the run;
passmcp does not retry without verification (
tlsFindingsininternal/probe/phase_net.go). - Blocked runs. When a phase sets
Session.blocked, every later phase is recorded as skipped with the reason, never passed. - Unevidenced passes. A check that returns
passwithout a request in its window or evidence it cites is recorded asinfo, unless it is a declared derived check (internal/probe/evidence.go, ADR-0002); the probe suite fails on any such pass (TestAPassWithoutEvidenceIsRecordedAsInfoand the guard in itsTestMain). - The hosted diagnostic.
passmcp serve --publicscans only an allowlist it was started with, and a missing allowlist admits nothing (TestAllowlistNilIsClosed, ADR-0005). The spec it runs is built from the endpoint, phases, report format, the policy's narrowing lists and bounded pacing; every other field a request sends is discarded (TestPublicSpecIsConstructed,TestPublicPacingIsBounded). At most eight runs execute at once, a request over that is answered 503 withRetry-After, an evicted run is cancelled, and stopping the server cancels every run it started (TestRunsInFlightAreBounded,TestEvictionCancelsTheRun,TestShutdownCancelsRuns). - The token store. On Unix a store readable by group or others is
refused rather than read (
TestStoreRefusesWorldReadableFile). On Windows a store whose access-control list lets another account read or change it, that has no list, or whose list cannot be read is refused (TestACLProblemRefusesAStoreAnotherAccountCanRead,TestStoreRefusesAnACLThatGrantsEveryone).
Complete mediation¶
Every access is checked, every time, not once per run.
- Every request goes through
Recorder.Wrap, so no request escapes redaction. - Every credentialed request is checked against the allowed origins
(
auth.Transport.mayCredential), and every redirect hop again byauth.CheckRedirect, capped atauth.MaxRedirects(5) (TestBearerDoesNotFollowRedirectOffOrigin,TestRedirectLeakIsRefused). - Every URL a server advertises is checked by
URLPolicy.Validatebefore use, and every connection is checked again at dial time byURLPolicy.DialContext, which dials only the addresses it checked, so a DNS answer that changes between the two is still caught (TestDialContextRefusesAnyNonPublicAnswer,TestAnAuthorizationServerThatRebindsIsNeverReached). Each layer fails closed on its own:Validaterefuses a name that does not resolve rather than leaving it to the dial (TestURLPolicyFailsClosedWhenANameDoesNotResolve). The exception is a run behind a proxy from the environment, where the proxy resolves names and the connection goes only to it (TestURLPolicyDefersToAProxyThatResolves); passmcp cannot see which address the proxy then reaches. - Every web shell request is checked for the per-run token and for its
origin (
TestGuards).
Open design¶
Nothing in passmcp's security depends on its design being secret. The source is GPL-3.0, the reasoning is in the decision records and this document, and a release can be rebuilt bit for bit from its tag (packaging.md). The only secrets are the operator's credentials and the web shell's per-run token.
Separation of privilege¶
Where one condition could be forged or mistaken, two are required.
- A mutating tool runs only when the tool declares
destructiveHint: falseand the operator passes--allow-mutations. Either alone is not enough (diagnostics.Policy.Decide). - A web shell request needs the per-run token and a same-origin
Origin(TestGuards). - A program started from the browser needs
--allow-stdioon the command line, and public mode refuses it whatever is passed. That covers a stdio target and a stdio server named in a request'sclient_config, which the catalog phase would start to list its tools (TestBrowserCannotStartAProgram,TestAllowStdioIsWhatPermitsIt,TestPublicModeNeverRunsAProgram,TestClientConfigCannotStartAProgram,TestPublicModeRefusesAClientConfigProgram).
Least privilege¶
Each part gets what its task needs and nothing more.
- On the server under test, passmcp reads by default and throttles
to
--rps(claim C3). - At the authorization server, it requests the scope the challenge
names, or what
--scopesays (section 5). - A stdio server does not inherit passmcp's environment; a variable
it needs is passed by name with
--stdio-env(TestStdioEnvIsNotInherited,TestStdioPassEnvForwardsByName). - In CI, every workflow sets its default token permissions to none
or read-only and grants write scopes per job, only where a job
publishes, signs or attests (
.github/workflows/).
Least common mechanism¶
The mechanism that carries credentials is not shared with the checks
that must run without them. The first-contact and invalid-token probes
use Session.Bare, a second transport that carries only the trace
header, so the bearer round-tripper cannot add the operator's token to
them (TestServerAcceptingGarbageTokenIsCritical,
ADR-0001).
Psychological acceptability¶
The safe path is the one with no flags, and a refusal says how to
proceed deliberately. A discovered endpoint on a private address is
refused with the flag that allows it (--insecure-allow-private-hosts);
an insecure token store is refused with the chmod 600 that fixes it
(creds.ErrInsecurePermissions). The flags that weaken a default say so
in their names: --insecure-allow-http-auth,
--insecure-allow-private-hosts, --allow-destructive.
8. Common weaknesses countered¶
The weaknesses below are the ones from MITRE's CWE list that apply to a network client holding credentials and reading hostile input. Each row names the countermeasure and the evidence for it.
| Weakness | How passmcp counters it | Evidence |
|---|---|---|
| CWE-918 Server-side request forgery: a server steering passmcp's requests | URLs a server advertises (resource metadata, authorization servers, token, authorization and registration endpoints, an A2A jku) must be HTTPS and resolve to public addresses; the check is repeated at dial time against DNS rebinding; credentials never follow a redirect off the allowed origins |
auth.URLPolicy, auth.OriginSet, auth.CheckRedirect; TestValidateMetadataChecksEveryEndpoint, TestDialContextConnectsToTheAddressesItChecked, TestAnAuthorizationServerThatRebindsIsNeverReached, TestCheckRedirect |
| CWE-200, CWE-532 Exposure of credentials, including in logs | Structural redaction at the one recorder, of every registered secret and every token seen mid-run; credential sources, never values, in banners; explain --curl emits placeholders; keyring secrets never reach a command line; the token store is 0600 in a 0700 directory, and on Windows carries an access-control list for the current user only |
Claim C1; TestFullRunClientCredentials, TestSecretsNeverSerialise, FuzzCredentialRedaction, TestCurlNeverCarriesASecret, TestKeychainSetKeepsSecretOutOfArgv, TestReportMasksReflectedSecrets |
| CWE-522 Insufficiently protected credentials in transit | OAuth endpoints must be HTTPS unless --insecure-allow-http-auth; plain HTTP to a non-loopback MCP endpoint is a critical finding |
auth.URLPolicy; phaseNet in internal/probe/phase_net.go |
| CWE-295 Improper certificate validation | TLS uses Go's standard verification everywhere; nothing outside tests sets InsecureSkipVerify; a failed handshake stops the run, and an expired certificate is critical |
tlsFindings, certWindowFinding; TestCertificateFailureIsReportedAgainstA824WithItsRequest, TestCertWindowFinding |
| CWE-400, CWE-770 Uncontrolled resource consumption | Response bodies capped (32 MiB, and 1 MiB for OAuth documents), SSE streams capped in bytes and events, schema recursion bounded at depth 64, pagination stops on a cursor cycle, telemetry bounded in events and body size, requests throttled to --rps, which is itself capped at 100 with --concurrency capped at 64, watch pulses no faster than every 30 s, a library client without a timeout of its own abandons a request after 60 s without progress, and explain with no client of its own abandons a model request after 10 min without progress |
transport.MaxResponseBytes, MaxStreamEvents, diagnostics.MaxSchemaDepth, Recorder.BodyCap, engine.ValidatePace, transport.IdleTimeout, enrich.DefaultIdleTimeout; TestValidateBoundsThePace, TestDefaultClientDoesNotWaitForeverOnASilentServer, TestNoClientDoesNotWaitForeverOnASilentAPI, TestIdleTimeoutKeepsAStreamThatMakesProgress, TestResponseBodyIsBounded, TestStreamEventsAreBounded, TestValidateDepthIsBounded, TestRecursiveRefTerminates, TestListToolsStopsOnACursorCycle, TestRecorderIsBounded, TestProbeSurvivesHostileServers, TestRunRefusesAnImpoliteInterval |
| CWE-20 Improper input validation | Every server-sent structure is parsed defensively, and the parsers of server bytes are fuzzed: WWW-Authenticate, SSE, JSON Schema, client configuration; run specifications are validated before a run; fleet names are restricted to a safe pattern |
engine.RunSpec.Validate; FuzzParseWWWAuthenticate, FuzzReadSSE, FuzzHeaderValue, FuzzValidate, FuzzSchemaValid, FuzzParse, FuzzRunSpecJSON; TestValidate, TestParseRefusesInlineSecretsAndMistakes |
| CWE-117 Improper output neutralization for logs | A finding's detail is collapsed to one line before it is recorded, so server text cannot forge a line in the text report or a log; structured logs are JSON-encoded; Markdown table cells are escaped; the HTML report is rendered through html/template and fuzzed; hidden bidi and zero-width characters are shown as code points in poisoning excerpts |
oneLine and truncate in internal/probe, esc in internal/report/render_md.go, diagnostics.visible; FuzzHTMLEscaping, TestHTMLEscapesHostileCatalog, TestScanTextFindsHiddenCharacters |
| CWE-150 Improper neutralization of escape, meta or control sequences | Server text written for a person (text and Markdown reports, TUI, human diagnostics, call/read/prompt/watch text) has ANSI escape sequences and C0/C1 control characters removed at the renderer; machine formats stay faithful and are escaped by their encoders |
internal/termsafe; TestStringRemovesControlSequences, TestTextAndMarkdownNeutraliseTerminalSequences, TestTextOutputsNeutraliseServerText |
| CWE-78, CWE-88 OS command and argument injection | No shell is ever invoked; a stdio server is executed as named with its arguments as given; keyring keys are encoded before they reach a helper; the web shell cannot start a program, as a target or as a client_config peer, unless --allow-stdio is passed |
transport/stdio.go, internal/creds/keyring.go, internal/web/server.go startsProgram; TestKeyNeverEscapesTheCommand, TestAHostileKeyCannotReachTheShell, TestBrowserCannotStartAProgram, TestClientConfigCannotStartAProgram |
| CWE-352, CWE-346 Cross-site request forgery and origin validation | passmcp login binds its redirect listener to 127.0.0.1, serves only the redirect URI's path there and closes it after five minutes without a redirect, checks state in constant time, always uses PKCE S256 and refuses a server that advertises PKCE methods without it, and checks the RFC 9207 iss; authorization server metadata must name, exactly, the issuer it was fetched for (RFC 8414 §3.3); the web shell requires its per-run token and a same-origin request |
auth.NewPKCE, auth.NewState, checkIssuer in client_auth.go, auth.IssuerMismatchError; TestLoginWrongState, TestLoginServesOnlyTheCallbackPath, TestLoginGivesUpWhenNoRedirectArrives, TestLoginRejectsMixUpIssuer, TestAuthorizationCodeFlow, TestDiscoverServerRequiresTheIssuerItAskedFor, TestAuthorizationServerNamingAnotherIssuerBlocks, TestGuards, TestRemoteBindRefused |
| CWE-22 Path traversal | Report files have fixed names inside the operator's directory; names derived from data are hashes (discovery) or must match a safe pattern (fleet) | engine.Result.WriteDir, safeName in internal/fleet/file.go; TestParseRefusesInlineSecretsAndMistakes |
| CWE-798 Hard-coded credentials | No credential is compiled in; every one comes from the operator. Gitleaks scans every push to main, every pull request and, weekly, the whole history |
.github/workflows/secret-scan.yml |
| CWE-494 Download of code without integrity check | Release artefacts carry SHA-256 checksums, keyless cosign signatures and SLSA provenance, and rebuild bit for bit | Claim C5; pkg/VERIFY.md, packaging.md |
9. Review and update¶
This document is re-reviewed on every release that touches:
- Any file in
internal/telemetry/,internal/creds/,auth/ortransport/. - Any change to
diagnostics.Policy. - Any file in
.github/workflows/. - Any new dependency.
Otherwise it is re-reviewed annually.
If you have questions or believe a claim above is not adequately supported by the linked evidence, please file a security advisory per SECURITY.md.