Skip to content

No frame of a publish holds the token as a plain value; the host's errors never quote their argument (typedstandards#141 P5) - #10

Merged
npstorey merged 4 commits into
mainfrom
ts141/p5-hardening
Oct 10, 2026
Merged

npstorey merged 4 commits into
mainfrom
ts141/p5-hardening

Conversation

@npstorey

@npstorey npstorey commented Oct 10, 2026 •

Copy link
Copy Markdown
Owner

P5 of the notebook-publish sprint (npstorey/typedstandards#141; the owner's rulings P4-2 = A and P4-3 = A, 2026-10-09). There is no version bump in this PR: 0.2.1 is cut by a separate release PR after it merges, and the changes are under ## Unreleased.

The rules this PR keeps.

  • The token reaches each request through an httpx.Auth that holds it in a slot and withholds it from its repr, whether with a client= the caller gives or with the client the call builds. No header dict carries it.
  • When an exception escapes a request, the locals of the frames it carries (httpx's, httpcore's and h11's included, and those of chained exceptions) are cleared before it propagates. So a traceback with frame locals does not show the token. A non-transport error, KeyboardInterrupt included, is re-raised as itself.
  • GitHubPagesHost's errors never quote their argument. A repository or branch with a part shaped like a GitHub token (a token prefix, then 30 or more token characters) is refused; a short name with a token prefix, such as ghp_notes, is accepted.

Branch and blast zone

ts141/p5-hardening, head 003a7d32b0ebbbb22a23cde06e812875e2b20334, on bdcf377 (main, 0.2.0). There are four commits, each SSH-signed with a sign-off. Round 1 is df0bcc9 (red) and e061ad8. Round 2, after the seat's review, is 9d45109 (red) and 003a7d3. The numstat below is round 1's; round 2 adds tests/test_publish_transport.py (+179) and changes _publish.py (+30 -4), tests/test_publish_token.py (+47 -2), README.md and CHANGELOG.md. uv.lock is unchanged.

8	0	CHANGELOG.md
5	3	README.md
76	20	src/typedstandards/_publish.py
11	1	tests/publish_support.py
81	7	tests/test_publish_token.py

tests/guards.py, tests/test_guards.py, .gitleaks.toml, pyproject.toml, package.json, package-lock.json and src/typedstandards/__init__.py are unchanged. No pinned test changed. publish_support.host() gained a mode argument, whose default is the earlier behaviour.

Red, then green

At df0bcc9, the red commit, ORCH reproduced: 7 failed, 36 passed, 1 skipped in tests/test_publish_token.py.

FAILED test_no_frame_holds_the_token_when_an_exception_escapes_the_client[an error raised inside the transport-client]
FAILED test_no_frame_holds_the_token_when_an_exception_escapes_the_client[an interrupt inside the transport-client]
FAILED test_no_frame_holds_the_token_when_an_exception_escapes_the_client[a body that does not decode-client]
FAILED test_no_frame_holds_the_token_when_an_exception_escapes_the_client[an error raised by an event hook-client]
FAILED test_the_hosts_errors_never_quote_their_argument[repository]
FAILED test_the_hosts_errors_never_quote_their_argument[branch]
FAILED test_the_hosts_errors_never_quote_their_argument[api_url]
  • At e061ad8, the token scanner runs every publish path with a given client and with the client the call builds. It also checks each traceback with frame locals when an exception escapes the HTTP client, for each of four exception kinds. The event-hook case applies to a given client only; that is the one skip.
  • ORCH ran an independent probe on e061ad8, walking every package frame's locals: none holds the token, with a given client or the call's own. Neither the repository nor the branch error shows it.

Checks (ORCH, at e061ad8)

uv sync --locked; uv run pytest   (Python 3.12, Node 24)  -> 379 passed, 1 skipped
uv run --python 3.11 pytest                               -> 379 passed, 1 skipped
uv run ruff check .                                       -> All checks passed!
uv run ruff format --check .                              -> 46 files already formatted
gitleaks git --log-opts=origin/main..HEAD                 -> no leaks found

The implementing session also ran Python 3.14 with Node 24, and Python 3.12 with Node 22 (379 passed, 1 skipped each). It ran the wheel job from a fresh clone too: uv build, a fresh install, scripts/smoke_wheel.py, smoke check passed. The keyword-scan emulation reports 0 hits.

Round 2 (9d45109 red, 003a7d3 green), after the seat's review of e061ad8

The seat found that round 1's texts claimed more than its tests measured, because they drove httpx's mock transport only. Round 2 makes the rule hold on the real transport, and narrows the texts to what is tested.

  • A test on httpx's real transport (tests/test_publish_transport.py). It uses httpx.HTTPTransport with httpcore's sync backend replaced by a stream that opens no socket and whose write fails. It runs 3 failure kinds × publish's own client and a given client. It walks every frame local of the escaping exception and its chain, and the traceback formatted with locals. A control test shows the walk reads httpcore's frames.
  • Red at 9d45109, reproduced by ORCH: 8 failed, the 4 real-transport cases and 4 short-name cases. Green at 003a7d3.
  • ORCH's independent probe on the real transport (httpcore 1.0.9, h11 0.16.0), with a write that raises KeyboardInterrupt: no frame holds the token at 003a7d3, with publish's own client or a given one, and the formatted traceback does not show it.
  • Checks at 003a7d3 (ORCH): 392 passed, 1 skipped on Python 3.11, 3.12 and 3.14 with Node 24; ruff clean; gitleaks clean. The implementing session also ran Python 3.12 with Node 22, and the wheel job from a fresh clone (smoke check passed).
  • One earlier test changed: test_the_scanner_fails_on_an_offender[frame local]. Its deliberately leaking frame sat inside the transport, which the fix now clears, so it now leaks from a later step instead. The assertion is unchanged.
  • A note for later: the real-transport test imports httpcore internals. Rerun it first after any httpcore upgrade in the lock.

Model

Implemented by IMPL P2/P5 on Opus 5.5 at high effort, in two rounds. Re-verified by ORCH NOTEBOOK-PUBLISH on Opus 5.5.

🤖 Generated with Claude Code

npstorey and others added 2 commits October 9, 2026 20:00
…never quote their argument

The token scanner now runs every publish path with a client the caller gives as well as with the
client publish builds, and checks each traceback with frame locals when an exception escapes the
HTTP client (from the transport, an event hook, an undecodable body, or an interrupt).
GitHubPagesHost's argument errors are checked for the value given and for the token= given beside
it, in str, repr and the traceback with locals.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LJBCtCpvKX2vdg53xqw8ji
Signed-off-by: Nathan Storey <npstorey@users.noreply.github.com>
…rors never quote their argument

The token reaches each request through an httpx.Auth that holds it, as a _Token, in a slot and
withholds it from its repr, with a client the caller gives and with the client publish builds.
No header dict, the client's or a request's, carries it.

GitHubPagesHost checks its arguments in a helper whose messages quote none of them, and its
constructor keeps no argument as a frame local when it raises. A repository or branch shaped like
a GitHub token is refused. The test of the host's errors calls through module constants, so the
only frames it checks are the package's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LJBCtCpvKX2vdg53xqw8ji
Signed-off-by: Nathan Storey <npstorey@users.noreply.github.com>
npstorey and others added 2 commits October 9, 2026 23:10
…haped names are refused

tests/test_publish_transport.py drives httpx.HTTPTransport, so each request head is written
through httpcore and h11, over a replaced sync network backend whose stream opens no socket and
whose write fails: an interrupt, an error that is not a transport error, or a transport error.
With publish's own client and with a given one, it walks every frame local of the exception that
escapes, of its __cause__ and __context__, and the traceback formatted with frame locals. A
control test shows the walk reads httpcore's frames.

GitHubPagesHost accepts a short repository or branch name that starts with a token prefix, and
refuses a token-shaped one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LJBCtCpvKX2vdg53xqw8ji
Signed-off-by: Nathan Storey <npstorey@users.noreply.github.com>
…token; only token-shaped names are refused

_Api._send clears the locals of every finished frame carried by an exception that escapes
Client.request, and by the exceptions chained to it, for every exception kind: a transport error
before it becomes publish's own error, and anything else, an interrupt included, before it is
re-raised as itself. httpcore and h11 hold the request head while they write it; those frames
are among the cleared. Frames still executing are left as they are. Nothing in publish catches
such an exception before _send.

GitHubPagesHost refuses a repository or branch part that is token-shaped: a GitHub token prefix
followed by 30 or more token characters, below the 36 of the shortest documented token. A short
name with a token prefix is accepted.

The README, the CHANGELOG and the comment at _Api.__init__ state what the tests check, on httpx's
mock transport and on its real transport over a failing stream. The token scanner's frame-local
offender now raises from a step after the request, since publish clears the frames below one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LJBCtCpvKX2vdg53xqw8ji
Signed-off-by: Nathan Storey <npstorey@users.noreply.github.com>
@npstorey
npstorey merged commit c1287a8 into main Oct 10, 2026
18 checks passed
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