Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 72 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,78 @@

Notable changes, newest first. Dates are ISO 8601.

## Unreleased
## 1.4.0 — 2026-08-18

A minor rather than a patch: `plainsong.runtime.localhost` is a new public
module, and a `Host` header that was accepted before is refused now. Nothing
about notation changes -- 6,321 files compile to exactly the music they did in
1.0.0.

### The loopback check let a domain through, and there were two of it

Both local servers — `plainsong serve` and `plainsong mcp --http` — refuse a
request whose `Host` is not this machine. That is the guard against DNS
rebinding, and comparing `Origin` against `Host` does not replace it: point
`evil.example` at 127.0.0.1 and a page served from that domain sends both
headers reading `evil.example`, matching perfectly. What gives it away is the
name itself.

The name was matched with `name.startswith("127.")`. `127.evil.example` starts
with those characters, is registrable, and can be pointed anywhere — so the
test meant to recognise the 127/8 block admitted the exact attack the guard
exists to stop. An address is parsed as an address now, not compared as a
string.

The same eight lines also read a bracketed IPv6 `Host` wrongly. The brackets
are what separate the address from the port, so stripping the port first turned
`[::1]` — which is what a client sends when the port is the default — into
`":"`, and a loopback caller was refused.

Both faults were in both servers, because the second was a copy of the first.
There is now one `plainsong/runtime/localhost.py`, and `tests/test_localhost.py`
fails if a third appears. The rebinding refusal is pinned over a real socket on
both servers, with and without an `Origin` header — the `Host` check has to run
before `Origin` is read, since a non-browser client simply omits `Origin` and an
`Origin`-only guard then has nothing to say.

`bind_is_loopback` is separated from `host_is_local` in the same move, because
they are different questions: a request addressed to `0.0.0.0` is legitimate,
and a server *bound* to `0.0.0.0` is what the "anyone who can reach this port"
warning is for.

The sibling `SuperInstance/plainsong-mcp` carries its own copy and needs the
same fix.

### Every release run had failed, for a reason nobody had read

Five tags, four versions on PyPI, and zero GitHub Releases. 1.3.0 blamed this on
`skip-existing` and shipped a fix for it. That was the wrong diagnosis. Reading
the job logs rather than reasoning about them, all six runs died at the same
step with the same error:

```
invalid-publisher: valid token, but no corresponding publisher
environment: MISSING
```

No Trusted Publisher was ever configured on PyPI. Every version up there had
been uploaded by hand. `skip-existing` is still worth having — it is what makes
a re-run idempotent — but it was never the blocker, and the `test` and `build`
jobs passing in all six runs is what made the failure easy to keep mis-reading.

`docs/releasing.md` is rewritten around this. It had been describing a project
that "has never been published", still sending the reader to the *pending*
publisher page — which refuses a name that already exists rather than
redirecting — and telling them to publish the sibling "the same way" when the
sibling has no release workflow at all. It now says which of PyPI's two pages
applies when, that the claims printed in the error are exactly what the
publisher must match, and that `environment: MISSING` means leave the field
empty.

One consequence worth knowing: a re-run uses the workflow file **as it was at
the tag**. `v1.0.1`, `v1.1.0` and `v1.2.0` predate `skip-existing`, so those
three cannot be backfilled by re-running — they authenticate and then abort on
files already on PyPI. From `v1.3.0` on, a re-run completes.

### The demo has a URL

Expand Down
12 changes: 12 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,18 @@ famous for came from those rows and are now 2.
MCP. Fixed there, with tests, but it sat open for months with nothing in
either repository able to notice.

It then cost a second time, and inside this repository. The guard also lived
in `interfaces/web/server.py`, and the MCP copy was a copy of it — so when
the shared eight lines got two things wrong, both servers got both wrong.
`name.startswith("127.")` admits `127.evil.example`, a registrable domain
that can be pointed at 127.0.0.1, which is precisely the attack; and
stripping a port before removing IPv6 brackets reads `[::1]` as `":"` and
refuses a real loopback caller. **Whether a name is loopback now lives only
in `runtime/localhost.py`**, `tests/test_localhost.py` fails if a module
starts deciding it again, and `bind_is_loopback` is kept separate from
`host_is_local` because `0.0.0.0` is a fine thing to be addressed as and a
bad thing to be bound to. The sibling still has its own copy.

The injection machinery in `mcp/tools.py` and `mcp/resources.py` — the
`ensemble=` parameter and `_default_ensemble()` — is **unused**. It was
written so the sibling could import this `tools.py` and pass its own ensemble;
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -483,7 +483,7 @@ in the documentation that stopped compiling would fail the build.

## Status

Version 1.3. The notation, the CLI surface and the provider catalogue format are
Version 1.4. The notation, the CLI surface and the provider catalogue format are
stable; changes to them will go through a deprecation cycle.

This release is a rebuild rather than an increment. The previous engine assumed a
Expand Down
113 changes: 52 additions & 61 deletions docs/releasing.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,77 +65,68 @@ needs the real history.
files reached PyPI some other way -- a manual `twine upload`, or an attempt
that uploaded and then failed later -- can never be re-run, because the upload
aborts on "file already exists" and the **release** step below never gets to
run. That is exactly how 1.0.1, 1.1.0 and 1.2.0 ended up as tags with no
GitHub Release.
run. It is not, however, why the early tags have no GitHub Release -- that was
the publisher, below.
4. **release** — creates the GitHub Release with the artifacts attached and
generated notes. This needs `contents: write`; without it the step fails at the
very end, after a successful publish, which is the worst place to find out.

## Before the first release
## Trusted Publishing

Nothing has ever been published. `plainsong` is unclaimed on PyPI, and so is
`plainsong-mcp`. Two things must happen once, in this order, and neither can be
done from inside this repository.
The workflow authenticates to PyPI with a short-lived OIDC credential, which is
why the publish job declares `id-token: write`. There is no API token in this
repository and there should never be one.

### 1. Claim the name with Trusted Publishing

Trusted Publishing is how the workflow authenticates. There is no API token in
this repository and there should never be one — the job proves its identity with
a short-lived OIDC credential, which is why it declares `id-token: write`.

Because `plainsong` does not exist on PyPI yet, this is a **pending publisher**,
created at <https://pypi.org/manage/account/publishing/> rather than on a project
page that does not exist. Fill in:
The publisher is configured **on PyPI, not here**, and getting it wrong is not
loud: the build succeeds, the artifacts are produced, and the publish step dies
at the end with `invalid-publisher: valid token, but no corresponding
publisher`. Because **release** needs **publish**, no GitHub Release is created
either. That is how `v1.0.1`, `v1.1.0`, `v1.2.0` and `v1.3.0` came to be four
tags with no releases while all four versions sat on PyPI, uploaded by hand.

| field | value |
|---|---|
| PyPI project name | `plainsong` |
| Owner | `SuperInstance` |
| Repository name | `plainsong` |
| Workflow name | `release.yml` |
| Environment name | *leave empty* |

The repository field is `plainsong`, not `plainsong-studio` — the GitHub
repository was renamed. A publisher configured against the old name authenticates
nothing.

### 2. Check the version and the changelog, then tag

```bash
# The version lives in two places and a test fails if they disagree.
grep version pyproject.toml plainsong/version.py

# CHANGELOG.md must have the release under its number and today's date,
# with no `## Unreleased` section left above it.

git tag -a v1.0.0 -m "1.0.0"
git push origin v1.0.0
```

The workflow does the rest. Watch it: the publish step runs after a successful
build, so an authentication failure there means the artefacts were built and
thrown away, and you fix it on PyPI and re-tag rather than in this repository.

### What to do if the tag was wrong

Delete the tag locally and remotely, fix the tree, tag again. A version number
that reached PyPI cannot be reused — PyPI refuses a filename it has already
seen, even after a delete — so if a bad `1.0.0` publishes, the next release is
`1.0.1` and `1.0.0` stays broken forever. That is why the workflow refuses a tag
that disagrees with `plainsong/version.py` before it builds anything.

### Then the sibling

`plainsong-mcp` depends on this package by git URL while this one is unpublished:

```toml
plainsong @ git+https://github.com/SuperInstance/plainsong@master
```

Once `plainsong` is on PyPI, change that to a version specifier
(`plainsong>=1.0`) and publish `plainsong-mcp` the same way — its own pending
publisher, owner `SuperInstance`, repository `plainsong-mcp`, workflow
`release.yml`. It is the only line in that repository that has to change.
| Repository | `plainsong` |
| Workflow | `release.yml` |
| Environment | *leave empty* |

**Which page you use depends on whether the project exists.** PyPI has two, and
the account-level one refuses a name that is already taken rather than
redirecting you:

- The project exists → its own settings,
<https://pypi.org/manage/project/plainsong/settings/publishing/>.
- The name is unclaimed → a *pending* publisher at
<https://pypi.org/manage/account/publishing/>. This is the only case where the
"PyPI project name" box is yours to fill in.

The repository field is `plainsong`, not `plainsong-studio` or
`tapscript-studio` — the GitHub repository has been renamed twice, and a
publisher configured against an old name authenticates nothing. If a run fails,
read the claims the error prints: `repository`, `workflow_ref` and `environment`
are exactly what the publisher has to match, and `environment: MISSING` means
leave that field empty rather than guessing at a name.

### Re-running a failed release

Once the publisher is fixed, "Re-run failed jobs" on the run will publish and
create the release — **but only if that tag's workflow carries `skip-existing`.**
A re-run uses the workflow file as it was at the tag, not as it is on master. At
`v1.0.1`, `v1.1.0` and `v1.2.0` it does not, so those re-runs authenticate and
then abort on "file already exists" for files that are already on PyPI. Those
three cannot be backfilled by re-running; the changelog is their record. From
`v1.3.0` on, the flag is in the tree and a re-run completes.

## The sibling

`plainsong-mcp` is a separate repository and a separate release. It depends on
this package by version specifier (`plainsong>=1.1.0`), so this one ships first
and the floor is raised there afterwards, never the reverse.

It needs its own pending publisher — owner `SuperInstance`, repository
`plainsong-mcp`, workflow `release.yml`, environment empty — created at the
account-level page above, because the name is still unclaimed on PyPI.

## What ships in the wheel

Expand Down
17 changes: 5 additions & 12 deletions plainsong/interfaces/web/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from urllib.parse import parse_qs, unquote, urlparse

from ...runtime.config import Config, load_config
from ...runtime.localhost import bind_is_loopback, host_is_local
from ...version import __version__

WEB_ROOT = Path(__file__).parent
Expand Down Expand Up @@ -197,18 +198,10 @@ def _json(self, payload: Any, status: int = 200) -> None:
def _host_is_local(self) -> bool:
"""Whether the Host header names this machine rather than a domain.

Comparing Origin against Host alone is defeated by DNS rebinding: an
attacker points `evil.example` at 127.0.0.1, and a page on that
domain then sends Origin and Host both reading `evil.example`, which
match perfectly. Requiring the Host to be a loopback name breaks
that, because a rebound request always carries the attacker's
hostname in Host.
Comparing Origin against Host alone is defeated by DNS rebinding;
see `runtime.localhost`, which both local servers share.
"""
host = self.headers.get("Host", "")
name = host.rsplit(":", 1)[0].strip("[]").lower() if host else ""
return name in {"localhost", "127.0.0.1", "::1", "0.0.0.0", ""} or name.startswith(
"127."
)
return host_is_local(self.headers.get("Host", ""))

def _same_origin(self) -> bool:
"""Reject cross-origin writes; this is a local tool, not an API."""
Expand Down Expand Up @@ -346,7 +339,7 @@ def serve(
out.head("plainsong web")
out.say(f" {url}")
out.dim(f" workspace {config.paths.workspace}")
if host not in ("127.0.0.1", "localhost", "::1"):
if not bind_is_loopback(host):
out.warn("bound to a non-loopback address -- anyone who can reach this port can use it")
out.say()
out.dim(" ctrl-c to stop")
Expand Down
15 changes: 5 additions & 10 deletions plainsong/mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
from urllib.parse import urlparse

from ..runtime.config import Config, load_config
from ..runtime.localhost import bind_is_loopback, host_is_local
from ..version import __version__
from . import protocol
from . import tools as mcp_tools
Expand Down Expand Up @@ -283,16 +284,10 @@ def _send(self, status: int, body: bytes, content_type: str = "application/json"
def _host_is_local(self) -> bool:
"""Whether Host names this machine rather than a domain.

Origin-against-Host alone is defeated by DNS rebinding: point
`evil.example` at 127.0.0.1 and both headers read `evil.example`,
matching perfectly. A rebound request always carries the attacker's
hostname, so requiring a loopback Host breaks it.
Origin-against-Host alone is defeated by DNS rebinding; see
`runtime.localhost`, which both local servers share.
"""
host = self.headers.get("Host", "")
name = host.rsplit(":", 1)[0].strip("[]").lower() if host else ""
return name in {"localhost", "127.0.0.1", "::1", "0.0.0.0", ""} or name.startswith(
"127."
)
return host_is_local(self.headers.get("Host", ""))

def _same_origin(self) -> bool:
"""Refuse cross-origin calls. This is a local tool, not a service."""
Expand Down Expand Up @@ -362,7 +357,7 @@ def serve_http(

url = f"http://{host}:{http.server_port}"
lines = [f"plainsong mcp on {url}", f"workspace {config.paths.workspace}"]
if host not in ("127.0.0.1", "localhost", "::1"):
if not bind_is_loopback(host):
lines.append(
"warning: bound to a non-loopback address -- anyone who can reach this port "
"can run every tool on this machine's workspace"
Expand Down
Loading
Loading