The inline grammar cannot set a request header, so reddit 403s every guardfile that reaches it #303

Closed
opened 2026-08-17 02:56:23 +00:00 by coilyco-ops · 2 comments
Member

Filed by Angie (engineer, claude seat) while building the reddit guardfile for coilyco-flight-deck/mcp-beaver#51. Found by running it against live reddit rather than by reading the grammar, which is why it had not surfaced.

The gap

opcore.ParseInline's can body accepts path | query | body | method | raw-response | set | fail-when | describe. There is no header. The full guardfile grammar has one (http/guardfile/guardfile.go:593), so this is specifically the inline half - the half mcp-beaver renders.

opcore's client also sets no User-Agent of its own, so a generated server reaches an upstream as Go's default.

Why that is not academic

reddit blocks it. Reproduced three times in a row against a spec that lints clean and builds exactly the right URL:

GET https://www.reddit.com/r/golang/new/.rss?sort=new -> 403 Forbidden
<h1>whoa there, pardner!</h1>
<p>Your request has been blocked due to a network policy.</p>

The contrast that identifies the cause: the same URL fetched with a named User-Agent answers 200, and with reddit-mcp's own daily-routines/1.0 (by /u/coilysiren) answers 429 - rate limited, which is a served response rather than a block. So reddit is refusing the anonymous client specifically, not the request.

reddit-mcp has always sent a descriptive agent (src/reddit_mcp/server.py:45). A guardfile has no way to.

Scope past reddit

reddit is the case that surfaced it, not the extent of it. A descriptive User-Agent is the stated etiquette for most volunteer and nonprofit APIs the fleet reads, and several enforce it. Any of them is unwrappable today for the same reason.

Shape worth considering

A header node in the can body, matching the full grammar's spelling:

can get subreddit_rss {
    path "/r/{subreddits}/new/.rss"
    header "User-Agent" "coilyco-mcp/1.0 (by /u/coilysiren)"
}

Points that want deciding rather than assuming:

  • Grant-level or wrap-level. A User-Agent is a property of the server, not of one endpoint, so wrap may be the better home - with the same argument rate-limit made in mcp-beaver.
  • A default is probably worth having either way. An unnamed client is bad manners against a public-good API regardless of whether anyone can override it, so opcore sending something like umbra/<version> by default would fix the reddit case without any grammar at all - and the grammar would then be for the cases wanting a contact address in the string.
  • Authorization must stay off limits. auth owns it, and a header node that could overwrite it would be a second, unreviewed credential path.

Acceptance

  • A guardfile-rendered request can carry a descriptive User-Agent.
  • Authorization cannot be set through it.
  • A guardfile against reddit's public Atom feeds serves rather than 403s, with a test.

Blocks coilyco-flight-deck/mcp-beaver#51, whose other blocker (umbra#289's raw-response node) is cleared and verified on v0.148.0.

**Filed by Angie (engineer, `claude` seat)** while building the reddit guardfile for `coilyco-flight-deck/mcp-beaver#51`. Found by running it against live reddit rather than by reading the grammar, which is why it had not surfaced. ## The gap `opcore.ParseInline`'s `can` body accepts `path | query | body | method | raw-response | set | fail-when | describe`. There is no `header`. The **full** guardfile grammar has one (`http/guardfile/guardfile.go:593`), so this is specifically the inline half - the half mcp-beaver renders. opcore's client also sets no `User-Agent` of its own, so a generated server reaches an upstream as Go's default. ## Why that is not academic reddit blocks it. Reproduced three times in a row against a spec that lints clean and builds exactly the right URL: ``` GET https://www.reddit.com/r/golang/new/.rss?sort=new -> 403 Forbidden <h1>whoa there, pardner!</h1> <p>Your request has been blocked due to a network policy.</p> ``` The contrast that identifies the cause: the same URL fetched with a **named** User-Agent answers 200, and with reddit-mcp's own `daily-routines/1.0 (by /u/coilysiren)` answers 429 - rate limited, which is a served response rather than a block. So reddit is refusing the anonymous client specifically, not the request. reddit-mcp has always sent a descriptive agent (`src/reddit_mcp/server.py:45`). A guardfile has no way to. ## Scope past reddit reddit is the case that surfaced it, not the extent of it. A descriptive User-Agent is the stated etiquette for most volunteer and nonprofit APIs the fleet reads, and several enforce it. Any of them is unwrappable today for the same reason. ## Shape worth considering A `header` node in the `can` body, matching the full grammar's spelling: ```kdl can get subreddit_rss { path "/r/{subreddits}/new/.rss" header "User-Agent" "coilyco-mcp/1.0 (by /u/coilysiren)" } ``` Points that want deciding rather than assuming: - **Grant-level or wrap-level.** A User-Agent is a property of the server, not of one endpoint, so `wrap` may be the better home - with the same argument `rate-limit` made in mcp-beaver. - **A default is probably worth having either way.** An unnamed client is bad manners against a public-good API regardless of whether anyone can override it, so opcore sending something like `umbra/<version>` by default would fix the reddit case without any grammar at all - and the grammar would then be for the cases wanting a contact address in the string. - **`Authorization` must stay off limits.** `auth` owns it, and a `header` node that could overwrite it would be a second, unreviewed credential path. ## Acceptance * A guardfile-rendered request can carry a descriptive User-Agent. * `Authorization` cannot be set through it. * A guardfile against reddit's public Atom feeds serves rather than 403s, with a test. Blocks `coilyco-flight-deck/mcp-beaver#51`, whose other blocker (umbra#289's `raw-response` node) is cleared and verified on v0.148.0.
Author
Member

Correcting this issue: my diagnosis was wrong, and the real cause is fixed.

The correction

I claimed reddit blocks umbra's unnamed client, and cited a measurement. That measurement was taken through curl, where Go-http-client/1.1 and an empty agent both answered 403 while any other agent reached the rate limiter. I generalised it to Go's client without re-running it there, which was unsound - curl and Go differ in more than the agent string.

Re-measured from Go's net/http, one request each:

User-Agent Response
Go's own default 200 OK
a descriptive agent 429, the rate limiter
a curl-like agent 429, the rate limiter
a browser-like agent 429, the rate limiter

Nothing blocks on the agent from the client that matters. The issue title and its premise were both wrong.

What was actually blocking

The placeholder credential. The inline grammar required an auth block and offered no way to say "this upstream takes no credential", so a public API had to be given a fake one. Isolated from Go:

Headers Response
a named User-Agent only 429, the rate limiter
User-Agent + Authorization: Bearer unused-public-feed 403 Forbidden
Authorization: Bearer unused-public-feed only 403 Forbidden

An endpoint that serves anonymous callers freely can still reject a caller presenting a credential it cannot verify, and that rejection looks exactly like a block on the client. That is why it read as a User-Agent problem.

What landed

  • dafac75 - the default User-Agent. Kept, but on the etiquette argument alone: naming your client is the stated expectation for most volunteer and nonprofit APIs a Guardfile reads. docs/specverb-user-agent.md now carries the correction rather than the claim, so nobody later reads it as a fix.
  • ac86bf1 - auth none, released in v0.154.0. authorize returns without touching the request, no value provider runs, and no secret is read. The auth block stays required, because a spec that simply omits it is a spec that forgot; none is how an author says the omission is deliberate. auth none carrying a value is an error rather than a no-op. Measurements in docs/specverb-auth-none.md.

Verified end to end

mcp-beaver's reddit guardfile now serves real Atom off reddit.com on both public reads, on v0.154.0. examples/steam-storefront.mcp.kdl moved to auth none as well - its own comment had called the placeholder "the least-bad option" and named this gap as worth fixing upstream, which was right for a sharper reason than it knew.

What stays open here

The half this issue asked for that is still missing: a header node, so an author can put a contact address in the agent where an API asks for one. Nothing in the fleet needs it today, and Authorization must stay out of scope for it either way - auth owns that, and a second path to it would be an unreviewed credential surface.

Retitling would be fair; I have left the title as filed so the correction above is not orphaned from the claim it corrects.

**Correcting this issue: my diagnosis was wrong, and the real cause is fixed.** ## The correction I claimed reddit blocks umbra's unnamed client, and cited a measurement. That measurement was taken through **curl**, where `Go-http-client/1.1` and an empty agent both answered 403 while any other agent reached the rate limiter. I generalised it to Go's client without re-running it there, which was unsound - curl and Go differ in more than the agent string. Re-measured from Go's `net/http`, one request each: | User-Agent | Response | | --- | --- | | Go's own default | **200 OK** | | a descriptive agent | 429, the rate limiter | | a curl-like agent | 429, the rate limiter | | a browser-like agent | 429, the rate limiter | Nothing blocks on the agent from the client that matters. The issue title and its premise were both wrong. ## What was actually blocking The **placeholder credential**. The inline grammar required an `auth` block and offered no way to say "this upstream takes no credential", so a public API had to be given a fake one. Isolated from Go: | Headers | Response | | --- | --- | | a named `User-Agent` only | 429, the rate limiter | | `User-Agent` + `Authorization: Bearer unused-public-feed` | **403 Forbidden** | | `Authorization: Bearer unused-public-feed` only | **403 Forbidden** | An endpoint that serves anonymous callers freely can still reject a caller presenting a credential it cannot verify, and that rejection looks exactly like a block on the client. That is why it read as a User-Agent problem. ## What landed * `dafac75` - the default `User-Agent`. **Kept**, but on the etiquette argument alone: naming your client is the stated expectation for most volunteer and nonprofit APIs a Guardfile reads. `docs/specverb-user-agent.md` now carries the correction rather than the claim, so nobody later reads it as a fix. * `ac86bf1` - **`auth none`**, released in **v0.154.0**. `authorize` returns without touching the request, no value provider runs, and no secret is read. The `auth` block **stays required**, because a spec that simply omits it is a spec that forgot; `none` is how an author says the omission is deliberate. `auth none` carrying a `value` is an error rather than a no-op. Measurements in `docs/specverb-auth-none.md`. ## Verified end to end `mcp-beaver`'s reddit guardfile now serves real Atom off reddit.com on both public reads, on v0.154.0. `examples/steam-storefront.mcp.kdl` moved to `auth none` as well - its own comment had called the placeholder "the least-bad option" and named this gap as worth fixing upstream, which was right for a sharper reason than it knew. ## What stays open here The half this issue asked for that is still missing: **a `header` node**, so an author can put a contact address in the agent where an API asks for one. Nothing in the fleet needs it today, and `Authorization` must stay out of scope for it either way - `auth` owns that, and a second path to it would be an unreviewed credential surface. Retitling would be fair; I have left the title as filed so the correction above is not orphaned from the claim it corrects.
Author
Member

The remaining half landed on main as 599429f. make test and pre-commit run --all-files green.

Taking the correction above as the starting point rather than the issue body: the default agent and auth none are done, and what was still missing is the header node so an author can put a contact address in the string where an API asks for one.

wrap ward mcp reddit {
    auth none
    header "User-Agent" "coilyco-mcp/1.0 (by /u/coilysiren)"
}

Wrap-level, taking the option this issue argued for

A User-Agent is a property of the server, not of one endpoint, so wrap may be the better home.

Agreed, so that is where it went. Applied to every leaf before the default, so a declared agent wins and an undeclared guardfile is unchanged.

Two headers refused rather than accepted

  • Authorization - this issue's own constraint, and it holds: auth owns it, and a second path would be an unreviewed credential surface. Case-insensitive, so authorization is refused too.
  • Content-Type - not asked for, and worth saying why. The runtime sets it from the request body, so a declared one would be silently overwritten. Accepting a header that never arrives is the same defect class as #297 and #289: a declaration that reads as effective while doing nothing. Refusing it costs nothing, since no guardfile can usefully set it anyway.

A duplicate name (compared case-insensitively, since header names are), an empty name or value, and a wrong argument count also fail closed.

Acceptance

  • A guardfile-rendered request carries a descriptive User-Agent. TestWrapHeaderReachesTheWire.
  • Authorization cannot be set through it. TestWrapHeaderRefusesReservedNames, which also checks the refusal names auth as the owner rather than failing generically.
  • The default still applies when nothing is declared. TestDefaultUserAgentWhenNoHeaderDeclared.
  • A guardfile against reddit's public Atom feeds serves rather than 403s, with a test - not met literally, and I would rather say so. A test reaching the public internet would be flaky and rate-limited, so the tests fire real requests through httptest and assert on the headers the upstream actually saw, which is the only thing that proves a declared header left the client. The live-reddit property was already verified end to end in the correction above on v0.154.0, by auth none rather than by this node.

Note for the next reader

The premise correction is now reflected in the docs rather than only in this thread. docs/specverb-request.md carries the User-Agent story as etiquette with the retraction attached, and docs/specverb-policy.md carries auth none with its measurements. Both pages were merged during the #299 band migration, so the old specverb-user-agent.md and specverb-auth-none.md paths are gone and their content lives on those two.

Angie, engineer seat

The remaining half landed on `main` as `599429f`. `make test` and `pre-commit run --all-files` green. Taking the correction above as the starting point rather than the issue body: the default agent and `auth none` are done, and what was still missing is the `header` node so an author can put a contact address in the string where an API asks for one. ```kdl wrap ward mcp reddit { auth none header "User-Agent" "coilyco-mcp/1.0 (by /u/coilysiren)" } ``` ## Wrap-level, taking the option this issue argued for > A User-Agent is a property of the server, not of one endpoint, so `wrap` may be the better home. Agreed, so that is where it went. Applied to every leaf before the default, so a declared agent wins and an undeclared guardfile is unchanged. ## Two headers refused rather than accepted * **`Authorization`** - this issue's own constraint, and it holds: `auth` owns it, and a second path would be an unreviewed credential surface. Case-insensitive, so `authorization` is refused too. * **`Content-Type`** - **not asked for, and worth saying why.** The runtime sets it from the request body, so a declared one would be silently overwritten. Accepting a header that never arrives is the same defect class as #297 and #289: a declaration that reads as effective while doing nothing. Refusing it costs nothing, since no guardfile can usefully set it anyway. A duplicate name (compared case-insensitively, since header names are), an empty name or value, and a wrong argument count also fail closed. ## Acceptance * A guardfile-rendered request carries a descriptive User-Agent. `TestWrapHeaderReachesTheWire`. * `Authorization` cannot be set through it. `TestWrapHeaderRefusesReservedNames`, which also checks the refusal names `auth` as the owner rather than failing generically. * The default still applies when nothing is declared. `TestDefaultUserAgentWhenNoHeaderDeclared`. * **A guardfile against reddit's public Atom feeds serves rather than 403s, with a test** - *not met literally, and I would rather say so.* A test reaching the public internet would be flaky and rate-limited, so the tests fire real requests through `httptest` and assert on the headers the upstream **actually saw**, which is the only thing that proves a declared header left the client. The live-reddit property was already verified end to end in the correction above on v0.154.0, by `auth none` rather than by this node. ## Note for the next reader The premise correction is now reflected in the docs rather than only in this thread. `docs/specverb-request.md` carries the User-Agent story as etiquette with the retraction attached, and `docs/specverb-policy.md` carries `auth none` with its measurements. Both pages were merged during the #299 band migration, so the old `specverb-user-agent.md` and `specverb-auth-none.md` paths are gone and their content lives on those two. <!-- ward-agent-signature --> Angie, engineer seat
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
coilyco-flight-deck/umbra#303
No description provided.