Skip to content

docs(plan): auth operator messages that survive pmcp's own sanitiser, the README /mcp rule behind a prefix-stripping proxy, no empty resource (see Consiliency/pmcp#326) - #333

Merged
ViperJuice merged 10 commits into
mainfrom
plan/326-auth-messages
Oct 3, 2026

Conversation

@ViperJuice

@ViperJuice ViperJuice commented Oct 3, 2026 •

Copy link
Copy Markdown
Collaborator

Detailed implementation plan for #326. Plan only: this PR adds one file, .consiliency/plans/detailed-326-auth-messages-20261003-0226.md. It ships no code.

What the plan finds (measured on main 89559db)

  • Four fixed texts are rewritten by the sanitiser, not one. The base redactor rules do it; the additive layer rewrites none of them. Three of the four predate PR auth: JWKS refresh cooldown, fetch timeout and shared backoff, operator-configured metadata resource, no-usable-keys 503 (see Consiliency/pmcp#231, Consiliency/pmcp#320) #325.

    Original After the sanitiser
    Token could not be verified with the published key. Token [REDACTED] not be verified…
    Missing bearer token. Missing bearer [REDACTED]
    Unsupported token algorithm. Unsupported token [REDACTED]
    shared-secret auth mode requires auth_token. shared-secret [REDACTED] mode requires auth_token.
  • No description reaches the HTTP wire. 401, 403 and 503 responses send fixed bodies and a WWW-Authenticate header with only resource or resource_metadata, error and scope. The mangled text is the exception's stored text. So the rewording changes nothing a client sees.

  • README /mcp. Behind a reverse proxy that strips a path prefix, a prefixed metadata URL returns 404. --oauth-audience fixes the published resource but not the 404. An ASGI root_path mount answers the metadata only at the doubled prefix. pmcp has no --root-path flag.

  • Empty resource. It is unreachable through normalize_auth_metadata. When forced, main serves 200 {"resource": ""}, which is invalid under RFC 9728.

Decisions

  • Reword the four texts and leave the sanitiser alone. Its base rules are frozen (security: the redactor is keyword-anchored, mangles prose, and misses common token shapes (S-12) #234 is additive-only), and loosening them would be fail-open.
  • The class test derives its list from the code. An AST walk finds 33 texts, and pyjwt generates its 9 kept texts. Each is checked against the base rules, the additive rules and the composed sanitiser. WWW-Authenticate is checked one parameter at a time; the whole header is pinned as redacted by design.
  • The prefix-proxy 404 is a separate issue. It is a behaviour change that needs a design choice. The drafted issue text is in the plan and is not filed from here. The README names the case and documents the working setup (RFC 9728 path form plus --oauth-audience).
  • Empty resource fails closed at startup. The route is not mounted. The plan rejects omitting resource (RFC 9728 requires it), a per-request assert (a 500 on a public endpoint) and leaving it as is.

Measured on the spike

  • The new module has 80 tests: 9 fail on main, all pass on the spike.
  • The auth, HTTP, redaction, CLI and server suites: 626 passed.
  • All 7 mutants are red, including a newly added mangled text, which the derivation catches.
  • ruff, mypy and check_security_claims.py are clean.
  • check_plan_consistency.py reports 0 blocking issues.

The patches and the test module are embedded verbatim. They were checked to reproduce the spike byte for byte from 89559db.

🤖 Generated with Claude Code

https://claude.ai/code/session_01DsWXuxhZkEQrfn7TN9GCN2


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

… the README /mcp rule behind a prefix-stripping proxy, no empty resource (see #326)

Detailed plan for #326, widened to the class: every fixed
message in auth.py and transport/http.py, derived by an AST walk (33 texts
plus 9 pyjwt texts), checked against the base redactor, the additive rules
and the composed sanitiser. Measured on main 89559db: four texts are
rewritten, not one (three predate #325); none reaches the HTTP wire. The
metadata 404 behind a prefix-stripping proxy is measured (--oauth-audience
fixes resource, not the route) and left to a separate issue with drafted
text; README names the case. Empty resource fails closed at startup.
Spiked: 80 tests, 9 red on main; 7/7 mutants red. This commit adds only
the plan file.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DsWXuxhZkEQrfn7TN9GCN2
ViperJuice and others added 9 commits October 3, 2026 03:14
…ins (see #326)

Round-1 panel (claude and codex): the derived message list failed open. It
read one positional argument of five callees, skipped unknown shapes and hid
a shrinking list behind a >= 28 floor. The walk now reads positional and
keyword arguments, renders names, f-strings, % and .format, classifies every
raise, requires every unrenderable message to be a reviewed exemption
(multiset), and pins the derived texts (39) and log templates (16) exactly.

Sinks settled by reading them: no log record passes through the sanitiser
(no filter, plain or verbatim-JSON formatter, pmcp logs prints raw), so log
templates are pinned but not reworded, with a tripwire test. Took claude's
N1-N4 and the loopback nit: the startup text names the API parameters; the
README separates CLI deployments (only --oauth-audience; the CLI serves no
metadata route) from embedders; the tripwire also asserts the literal path
serves 200.

Re-measured: 98 tests, 9 red on main; touched suites 644 passed; full suite
5027 passed; 24/25 mutants red, K7 survives by construction with the K7+C9
double mutant measured. This commit changes only the plan file.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DsWXuxhZkEQrfn7TN9GCN2
…asses and refuses ambiguous names; behavioural log check (see #326)

Round-2 panel (codex blocking, claude non-blocking, coordinator agreed with
codex): the walk still failed open on `+=`, an exception built then raised
by name, an alias raised by name, and a subclass carrying its message in
super().__init__; the log tripwire missed a redacting text formatter.

The walk now treats every call that resolves at test time to an exception
class (aliases, attribute chains, module-defined subclasses) as a message
call, follows a subclass's message into super().__init__, resolves
`raise <name>` to its one constructing assignment or fails, and renders a
name only when its sole binding in the innermost scope is one plain
assignment (refusing +=, parameters, for/with targets, walrus, unpacking,
global, reassignment). The log check runs the real setup_logging in text
and JSON mode through every handler.

Re-measured: 117 tests, 9 red on main; touched suites 663 passed; full suite
5046 passed; 34/36 mutants red, with K7 and K14 surviving by construction and
their double mutants measured. This commit changes only the plan file.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DsWXuxhZkEQrfn7TN9GCN2
…pass; the static collector is removed (see #326)

Round-3 panel (claude DISAGREE, codex 3 blocking): four more shapes beat the
static message collector (subclass constant or self attribute, += on a
parameter, comprehension walrus, alias resolved in the wrong scope). Fourth
round, so round 4 changes the design instead of adding rules.

Every fixed operator-facing text in auth.py and transport/http.py is now a
member of pmcp.auth.AuthMessage (29; JWKS URL and scope names are filled
from configuration by the constructor). ResourceServerAuthError,
ResourceServerJWKSUnavailable and the new _auth_response refuse anything
else with TypeError (always on), with one narrow pyjwt_text pass-through. A
syntax-only check (no resolver) requires every raise and _reject site to
name AuthMessage.<NAME> and forbids new exception classes. The resolver,
scope walker, exemption multiset and pins are gone. Logs are checked end to
end through the real logger, stderr and the log file (claude N3).

The first full-suite run caught three existing test files that built the
errors from strings (28 failures); they now pass members. Measured: 123-test
module green (collection-red on main), touched suites 877 passed, 20/20
mutants red, full suite 5052 passed. This commit changes only the plan file.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DsWXuxhZkEQrfn7TN9GCN2
…placeholder fields, lexical re-raises (see #326)

Round-4 panel: gemini clean; claude one blocker (B1) and N1/N2; codex two
blockers overlapping them. The registry design holds; its enforcement had
five holes, each closed with a regression test and mutants:
- membership is identity (an id set of the registry), not isinstance; a
  PyJwtText can be minted only by pyjwt_text (private token, checked);
- fields must be exactly the member's placeholders, so a missing field can
  no longer publish {url} and a misnamed one no longer raises KeyError; the
  static check requires field values to be runtime names;
- AsyncJWKS.get and _fetch re-raise TypeError/KeyError ahead of their broad
  except, so a programming error is not a 503 and opens no backoff;
- raise <name> is accepted only inside the handler that binds it, and every
  raise is counted (34);
- pyjwt_text(<name>) is accepted only inside except _FIXED_TEXT_CLAIM_ERRORS
  as <name>; challenge tests build lazily, so that mutant fails tests
  instead of erroring the module.
Scope names in MISSING_SCOPES (claude N3) are stated as a non-goal.

Measured: 141-test module, touched suites 895 passed, 31/31 mutants red,
full suite 5070 passed. This commit changes only the plan file.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DsWXuxhZkEQrfn7TN9GCN2
…ecked field values (see #326)

Round-5 panel: gemini clean, claude nothing blocking (N1-N4), codex two
blockers, both closed:
- _auth_response checked type, not membership; it now renders through the
  one identity-checked guard, and a structural test forbids any other
  isinstance(..., AuthText) in src/pmcp.
- A field value bound to prose passed. The renderer now checks each value's
  shape: {url} must be an absolute http(s) URL with no whitespace (reusing
  the absolute-URL rule sanitize_public_auth_url applies to configured auth
  URLs), {scopes} an RFC 6749 scope list; every placeholder has a validator,
  and an unknown one fails at import. The residual (prose made only of
  scope-token characters) is stated.
Claude's notes: the static check compares each site's field names with its
member's placeholders and a test drives the JWKS redirect path (N1); reload
staleness documented (N3); named-only fields pinned and the renderer always
formats (N4).

Measured: 157-test module, touched suites 911 passed, 38/38 mutants red,
full suite 5086 passed. This commit changes only the plan file.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DsWXuxhZkEQrfn7TN9GCN2
… unrenderable auth config at startup (see #326)

- Static check: each site kind has an exact message-position count and an
  allowed-keyword set (ValueError, HTTPError, both auth error classes,
  _reject/_auth_response); star-args refused. Runtime: _auth_response and
  _reject take headers keyword-only, _auth_response refuses non-mapping
  headers.
- One shared validator, check_auth_config, built on the renderer's own
  _url_field/_scopes_field, refuses a JWKS URL, metadata URL or required
  scope at startup in AsyncJWKS.__init__, create_http_app and the CLI/env
  path (_check_auth_args, exit 1).
- Re-measured: module 203 passed; touched suites 957 passed; full suite
  5132 passed, 0 failed; 49/49 mutants red.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DsWXuxhZkEQrfn7TN9GCN2
Brings the plan branch up to current main so the round-8 spike is measured
on 199ee7c (see #326).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DsWXuxhZkEQrfn7TN9GCN2
…k a listed-shape guard rail; startup checks on stored values where read (see #326)

- Promise narrowed: acceptance criteria split into the runtime guarantee
  (identity membership, field shapes, constructor/_auth_response arity,
  startup refusal) and a static guard rail for seven listed shape families,
  stated as not exhaustive against deliberate obfuscation.
- claude r7 B1: check_auth_config validates URLs as stored
  (sanitize_public_auth_url first); the CLI check runs only for HTTP +
  resource-server; create_http_app checks scopes only in that mode. Five
  must-start cases added.
- codex r7 1: static check refuses payload mutation of an except-bound
  exception (8 shapes).
- codex r7 2: metadata URL checked as configured, not as normalised; the
  behaviour change for create_http_app is stated, one pinned test migrated.
- Re-measured on 199ee7c: module 222 passed; step-2 suites 976 passed;
  full suite 5452 passed, 0 failed; 58/58 mutants red.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DsWXuxhZkEQrfn7TN9GCN2
…h the registry guard; sanitize's specific message kept (see #326)

- claude B1 + codex (round 8): all 13 builtin ValueError/HTTPError sites now
  raise ValueError(render_auth_message(AuthMessage.<NAME>, **fields)); the
  static check requires that form with field names equal to the member's
  placeholders; a renderer spy drives every builtin site; codex's swapped
  member is a TypeError, never a published {url}.
- claude N1: check_auth_config lets sanitize_public_auth_url's specific
  registry message through (IP-literal JWKS URL tested via the real CLI).
- claude N2: the list of now-refused metadata URLs is corrected (a ?token=
  query alone is not one; an https case is pinned as starting), and the
  migrated test says why its URL is refused.
- Re-measured on 199ee7c: module 244 passed; step-2 suites 998 passed;
  full suite 5474 passed, 0 failed; 64/64 mutants red.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DsWXuxhZkEQrfn7TN9GCN2
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant