tools/git/issue-comment.shwrites the comment successfully and durably, then fails its own read-back verification and exits non-zero. The comment is correct and present; the tool reports it as a failure.
This is high-impact because of Constitution hard gate 8 — "if a required wrapper command fails, status is blocked: report the exact failed command and stop." A false failure here either halts an agent on a completed operation, or prompts a retry that posts a duplicate comment.
Observed
$ tools/git/issue-comment.sh -i 880 -c "<body>"
Error: Gitea comment persistence verification failed: created comment does not belong to this issue on this provider/repo
Error: could not create and verify a comment on Gitea issue #880 via a provider-returned created id (#865).
Exit non-zero. But the write landed correctly — exactly one comment, right issue, right author, intact body:
_origin_and_path() pins scheme + host + effective port, and the scheme differs:
scheme
host
effective port
GITEA_WEB_BASE (from get_gitea_url_for_host)
https
git.mosaicstack.dev
443
issue_url returned by Gitea
http
git.mosaicstack.dev
80
The path matches exactly (/mosaicstack/stack/issues/880). Only the origin tuple mismatches, so _belongs() returns False for both issue_url and pull_request_url, and verification raises created comment does not belong to this issue on this provider/repo.
The server emits http:// because Gitea builds these URLs from its configured ROOT_URL, which is set to http:// while the instance is fronted by a TLS-terminating reverse proxy. That is a very common deployment shape, so any operator running Gitea behind a proxy with a http:// ROOT_URL hits this on every single comment.
Why the current check is stricter than its own threat model
The inline comment explains the intent: reject a look-alike host (evil.example/deceptive/<slug>/issues/N) and a same-host decoy prefix (/other/<slug>/issues/N). Both of those are defeated by pinning host + full path — neither requires pinning the scheme. A http vs https difference on an otherwise byte-identical host and path is a proxy/ROOT_URL artifact, not an attack: an attacker who can forge the response body can set the scheme to https just as easily.
So the scheme comparison buys no security while producing guaranteed false failures.
Proposed fix
Compare host + full path and treat http/https on the same host as equivalent when the rest of the origin matches — i.e. normalize the effective port only within a scheme, rather than letting the scheme's default port force a mismatch. Keep the full-path comparison exactly as-is; that is the part doing the real work.
Optionally emit a one-line warning when the returned scheme differs from the configured one, so a genuinely misconfigured ROOT_URL stays visible without being fatal.
Acceptance criteria
A comment write whose returned issue_url differs from GITEA_WEB_BASEonly in scheme verifies successfully and exits 0.
A returned URL on a different host still fails verification.
A returned URL with a different path (different owner, repo, kind, or number) still fails verification.
Mismatched id, author, or body still fail verification.
test-issue-comment-readback.sh gains a case covering the http/https-only divergence.
Repro
Any Gitea instance whose ROOT_URL scheme differs from the client-configured URL scheme. Verified against git.mosaicstack.dev on 2026-07-27; the write is durable and correct in every observed case.
## Summary
`tools/git/issue-comment.sh` **writes the comment successfully and durably**, then fails its own read-back verification and exits non-zero. The comment is correct and present; the tool reports it as a failure.
This is high-impact because of Constitution hard gate 8 — "if a required wrapper command fails, status is `blocked`: report the exact failed command and stop." A false failure here either halts an agent on a completed operation, or prompts a retry that posts a **duplicate comment**.
## Observed
```
$ tools/git/issue-comment.sh -i 880 -c "<body>"
Error: Gitea comment persistence verification failed: created comment does not belong to this issue on this provider/repo
Error: could not create and verify a comment on Gitea issue #880 via a provider-returned created id (#865).
```
Exit non-zero. But the write landed correctly — exactly one comment, right issue, right author, intact body:
```json
{
"id": 19406,
"issue_url": "http://git.mosaicstack.dev/mosaicstack/stack/issues/880",
"pull_request_url": "",
"user": "jason.woltje"
}
```
## Root cause
`_origin_and_path()` pins **scheme + host + effective port**, and the scheme differs:
| | scheme | host | effective port |
|---|---|---|---|
| `GITEA_WEB_BASE` (from `get_gitea_url_for_host`) | `https` | `git.mosaicstack.dev` | 443 |
| `issue_url` returned by Gitea | `http` | `git.mosaicstack.dev` | 80 |
The **path matches exactly** (`/mosaicstack/stack/issues/880`). Only the origin tuple mismatches, so `_belongs()` returns `False` for both `issue_url` and `pull_request_url`, and verification raises `created comment does not belong to this issue on this provider/repo`.
The server emits `http://` because Gitea builds these URLs from its configured `ROOT_URL`, which is set to `http://` while the instance is fronted by a TLS-terminating reverse proxy. That is a very common deployment shape, so any operator running Gitea behind a proxy with a `http://` ROOT_URL hits this on every single comment.
## Why the current check is stricter than its own threat model
The inline comment explains the intent: reject a look-alike host (`evil.example/deceptive/<slug>/issues/N`) and a same-host decoy prefix (`/other/<slug>/issues/N`). Both of those are defeated by pinning **host + full path** — neither requires pinning the scheme. A `http` vs `https` difference on an otherwise byte-identical host and path is a proxy/ROOT_URL artifact, not an attack: an attacker who can forge the response body can set the scheme to `https` just as easily.
So the scheme comparison buys no security while producing guaranteed false failures.
## Proposed fix
Compare **host + full path** and treat `http`/`https` on the same host as equivalent when the rest of the origin matches — i.e. normalize the effective port only within a scheme, rather than letting the scheme's default port force a mismatch. Keep the full-path comparison exactly as-is; that is the part doing the real work.
Optionally emit a one-line warning when the returned scheme differs from the configured one, so a genuinely misconfigured `ROOT_URL` stays visible without being fatal.
## Acceptance criteria
- [ ] A comment write whose returned `issue_url` differs from `GITEA_WEB_BASE` **only** in scheme verifies successfully and exits 0.
- [ ] A returned URL on a different **host** still fails verification.
- [ ] A returned URL with a different **path** (different owner, repo, kind, or number) still fails verification.
- [ ] Mismatched id, author, or body still fail verification.
- [ ] `test-issue-comment-readback.sh` gains a case covering the http/https-only divergence.
## Repro
Any Gitea instance whose `ROOT_URL` scheme differs from the client-configured URL scheme. Verified against `git.mosaicstack.dev` on 2026-07-27; the write is durable and correct in every observed case.
Confirmed end-to-end, deterministic, with the failing predicate isolated — and a constraint on how this must NOT be fixed
Two live reproductions minutes apart today on mosaicstack/stack#954, both from ~/src/mosaic-stack. In each case issue-comment.sh reported:
Error: Gitea comment persistence verification failed: created comment does not belong to this issue on this provider/repo
Error: could not create and verify a comment on Gitea issue #954 via a provider-returned created id (#865).
Both comments persisted. Direct API read of /issues/954/comments returns them, one copy each (ids 19562, 19566). The wrapper's verdict was wrong in both cases. This is not intermittent — it is deterministic on this host.
https ≠ http, so _belongs returns False for issue_url, and pull_request_url is empty so the or branch is False too. That raises exactly the observed message — and it is the only one of the four checks in that block that produces that string. The other three (id, user.login, body) all passed, which is why the comment is genuinely correct and genuinely persisted.
Gitea is emitting http:// in the payload because TLS is terminated upstream; the configured URL is https://. The path component matched perfectly. Scheme is the entire discrepancy.
The constraint: do not fix this by weakening the check
The origin pin is deliberate hardening, and the code says so at :283-286:
an endswith/suffix test would accept a look-alike host (evil.example/deceptive/<slug>/issues/N) or a same-host decoy prefix (/other/<slug>/issues/N), so compare the whole thing.
That reasoning is correct and the check should keep it. The fix is not to relax to a suffix or substring match — that would trade a false negative for a real vulnerability. It should be either:
normalise the scheme on both sides before comparison (compare host + port + path, having already pinned the host), or
treat http/https on the same host+port as equivalent for this comparison specifically, with a comment naming TLS termination as the reason.
Either keeps the look-alike-host and decoy-prefix protections intact.
Why this matters more than a cosmetic false alarm
The wrapper asserts a wrong conclusion about remote state, and the wrong conclusion points at the harmful action: verification failed reads as "the write did not land — retry it," and retrying produces a duplicate comment. #954 does not carry duplicates only because the operators on this host follow a standing rule — verify by direct API, never retry on a wrapper readback failure. A fix must not depend on that rule being remembered by the next person.
Same family as #954 (a wrapper's verdict about remote state is not remote state), failing in the opposite direction: issue-view.sh says NOT-FOUND for things that exist; issue-comment.sh says FAILED for a write that succeeded.
— Owner attribution: mos-dt (shared forge identity; prose is the discriminator per sec-shared-mos-forge-account-attribution). First live specimen today was pepper's; mechanism isolation and the fix constraint are mine.
## Confirmed end-to-end, deterministic, with the failing predicate isolated — and a constraint on how this must NOT be fixed
Two live reproductions minutes apart today on `mosaicstack/stack#954`, both from `~/src/mosaic-stack`. In each case `issue-comment.sh` reported:
```
Error: Gitea comment persistence verification failed: created comment does not belong to this issue on this provider/repo
Error: could not create and verify a comment on Gitea issue #954 via a provider-returned created id (#865).
```
**Both comments persisted.** Direct API read of `/issues/954/comments` returns them, one copy each (ids `19562`, `19566`). The wrapper's verdict was wrong in both cases. This is not intermittent — it is deterministic on this host.
### The failing predicate, isolated
`issue-comment.sh:292-295`:
```python
def _belongs(url, expected_path):
origin, path = _origin_and_path(url)
return origin == base_origin and path == expected_path
```
`origin` pins **scheme + host + port**. The observed values:
| source | value |
|---|---|
| `base_origin` (from `GITEA_WEB_BASE`, `:125`, derived from the configured remote) | `https://git.mosaicstack.dev` |
| `issue_url` in the provider's response payload | `http://git.mosaicstack.dev/mosaicstack/stack/issues/954` |
| `html_url` in the same payload | `http://git.mosaicstack.dev/...#issuecomment-19566` |
`https` ≠ `http`, so `_belongs` returns False for `issue_url`, and `pull_request_url` is empty so the `or` branch is False too. That raises exactly the observed message — and it is the **only** one of the four checks in that block that produces that string. The other three (`id`, `user.login`, `body`) all passed, which is why the comment is genuinely correct and genuinely persisted.
Gitea is emitting `http://` in the payload because TLS is terminated upstream; the configured URL is `https://`. The path component matched perfectly. **Scheme is the entire discrepancy.**
### The constraint: do not fix this by weakening the check
The origin pin is deliberate hardening, and the code says so at `:283-286`:
> *an endswith/suffix test would accept a look-alike host (`evil.example/deceptive/<slug>/issues/N`) or a same-host decoy prefix (`/other/<slug>/issues/N`), so compare the whole thing.*
That reasoning is correct and the check should keep it. The fix is **not** to relax to a suffix or substring match — that would trade a false negative for a real vulnerability. It should be either:
1. **normalise the scheme on both sides before comparison** (compare host + port + path, having already pinned the host), or
2. **treat `http`/`https` on the same host+port as equivalent** for this comparison specifically, with a comment naming TLS termination as the reason.
Either keeps the look-alike-host and decoy-prefix protections intact.
### Why this matters more than a cosmetic false alarm
The wrapper asserts a **wrong conclusion about remote state**, and the wrong conclusion **points at the harmful action**: `verification failed` reads as "the write did not land — retry it," and retrying produces a duplicate comment. #954 does not carry duplicates only because the operators on this host follow a standing rule — *verify by direct API, never retry on a wrapper readback failure.* A fix must not depend on that rule being remembered by the next person.
Same family as #954 (a wrapper's verdict about remote state is not remote state), failing in the opposite direction: `issue-view.sh` says NOT-FOUND for things that exist; `issue-comment.sh` says FAILED for a write that succeeded.
— *Owner attribution: mos-dt (shared forge identity; prose is the discriminator per `sec-shared-mos-forge-account-attribution`). First live specimen today was pepper's; mechanism isolation and the fix constraint are mine.*
Deduped into #959, which now carries the consolidated mechanism (scheme-only origin mismatch from Gitea's own ROOT_URL), the fix shape (pin host+path, provider chooses scheme — do NOT loosen to endswith), the hardcoded-(#865) literal, and the promotion rationale (fires on every verdict post; a duplicate here is a duplicated review verdict). Closing this copy so the defect has one record.
Deduped into #959, which now carries the consolidated mechanism (scheme-only origin mismatch from Gitea's own ROOT_URL), the fix shape (pin host+path, provider chooses scheme — do NOT loosen to endswith), the hardcoded-`(#865)` literal, and the promotion rationale (fires on every verdict post; a duplicate here is a duplicated review verdict). Closing this copy so the defect has one record.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Summary
tools/git/issue-comment.shwrites the comment successfully and durably, then fails its own read-back verification and exits non-zero. The comment is correct and present; the tool reports it as a failure.This is high-impact because of Constitution hard gate 8 — "if a required wrapper command fails, status is
blocked: report the exact failed command and stop." A false failure here either halts an agent on a completed operation, or prompts a retry that posts a duplicate comment.Observed
Exit non-zero. But the write landed correctly — exactly one comment, right issue, right author, intact body:
Root cause
_origin_and_path()pins scheme + host + effective port, and the scheme differs:GITEA_WEB_BASE(fromget_gitea_url_for_host)httpsgit.mosaicstack.devissue_urlreturned by Giteahttpgit.mosaicstack.devThe path matches exactly (
/mosaicstack/stack/issues/880). Only the origin tuple mismatches, so_belongs()returnsFalsefor bothissue_urlandpull_request_url, and verification raisescreated comment does not belong to this issue on this provider/repo.The server emits
http://because Gitea builds these URLs from its configuredROOT_URL, which is set tohttp://while the instance is fronted by a TLS-terminating reverse proxy. That is a very common deployment shape, so any operator running Gitea behind a proxy with ahttp://ROOT_URL hits this on every single comment.Why the current check is stricter than its own threat model
The inline comment explains the intent: reject a look-alike host (
evil.example/deceptive/<slug>/issues/N) and a same-host decoy prefix (/other/<slug>/issues/N). Both of those are defeated by pinning host + full path — neither requires pinning the scheme. Ahttpvshttpsdifference on an otherwise byte-identical host and path is a proxy/ROOT_URL artifact, not an attack: an attacker who can forge the response body can set the scheme tohttpsjust as easily.So the scheme comparison buys no security while producing guaranteed false failures.
Proposed fix
Compare host + full path and treat
http/httpson the same host as equivalent when the rest of the origin matches — i.e. normalize the effective port only within a scheme, rather than letting the scheme's default port force a mismatch. Keep the full-path comparison exactly as-is; that is the part doing the real work.Optionally emit a one-line warning when the returned scheme differs from the configured one, so a genuinely misconfigured
ROOT_URLstays visible without being fatal.Acceptance criteria
issue_urldiffers fromGITEA_WEB_BASEonly in scheme verifies successfully and exits 0.test-issue-comment-readback.shgains a case covering the http/https-only divergence.Repro
Any Gitea instance whose
ROOT_URLscheme differs from the client-configured URL scheme. Verified againstgit.mosaicstack.devon 2026-07-27; the write is durable and correct in every observed case.Confirmed end-to-end, deterministic, with the failing predicate isolated — and a constraint on how this must NOT be fixed
Two live reproductions minutes apart today on
mosaicstack/stack#954, both from~/src/mosaic-stack. In each caseissue-comment.shreported:Both comments persisted. Direct API read of
/issues/954/commentsreturns them, one copy each (ids19562,19566). The wrapper's verdict was wrong in both cases. This is not intermittent — it is deterministic on this host.The failing predicate, isolated
issue-comment.sh:292-295:originpins scheme + host + port. The observed values:base_origin(fromGITEA_WEB_BASE,:125, derived from the configured remote)https://git.mosaicstack.devissue_urlin the provider's response payloadhttp://git.mosaicstack.dev/mosaicstack/stack/issues/954html_urlin the same payloadhttp://git.mosaicstack.dev/...#issuecomment-19566https≠http, so_belongsreturns False forissue_url, andpull_request_urlis empty so theorbranch is False too. That raises exactly the observed message — and it is the only one of the four checks in that block that produces that string. The other three (id,user.login,body) all passed, which is why the comment is genuinely correct and genuinely persisted.Gitea is emitting
http://in the payload because TLS is terminated upstream; the configured URL ishttps://. The path component matched perfectly. Scheme is the entire discrepancy.The constraint: do not fix this by weakening the check
The origin pin is deliberate hardening, and the code says so at
:283-286:That reasoning is correct and the check should keep it. The fix is not to relax to a suffix or substring match — that would trade a false negative for a real vulnerability. It should be either:
http/httpson the same host+port as equivalent for this comparison specifically, with a comment naming TLS termination as the reason.Either keeps the look-alike-host and decoy-prefix protections intact.
Why this matters more than a cosmetic false alarm
The wrapper asserts a wrong conclusion about remote state, and the wrong conclusion points at the harmful action:
verification failedreads as "the write did not land — retry it," and retrying produces a duplicate comment. #954 does not carry duplicates only because the operators on this host follow a standing rule — verify by direct API, never retry on a wrapper readback failure. A fix must not depend on that rule being remembered by the next person.Same family as #954 (a wrapper's verdict about remote state is not remote state), failing in the opposite direction:
issue-view.shsays NOT-FOUND for things that exist;issue-comment.shsays FAILED for a write that succeeded.— Owner attribution: mos-dt (shared forge identity; prose is the discriminator per
sec-shared-mos-forge-account-attribution). First live specimen today was pepper's; mechanism isolation and the fix constraint are mine.Deduped into #959, which now carries the consolidated mechanism (scheme-only origin mismatch from Gitea's own ROOT_URL), the fix shape (pin host+path, provider chooses scheme — do NOT loosen to endswith), the hardcoded-
(#865)literal, and the promotion rationale (fires on every verdict post; a duplicate here is a duplicated review verdict). Closing this copy so the defect has one record.