HOMELAB-2529: fix(plane): assert comment identity before posting #77
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "plane/HOMELAB-2529-comment-identity"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
What
plane_commentand the sidebar's add-comment posted with whatever token was inPLANE_API_KEYand never asked the API whose token that was. Authorship is not decoration on this path: the pipeline writes verification records (HOMELAB-1679) and stage evidence to tickets, so a record attributed to an unknown user is a record nobody can rely on. Same structural gap HOMELAB-2527 fixed for Forgejo, on the Plane side. No live misattribution was ever demonstrated — characterization and the guard are the whole change.Red-first (pre-fix, fake API, no network)
All three refusal cases POSTed anyway against the unmodified tool:
The probe was deleted after capture;
plane/tests/comment-identity.test.tsencodes the same cases plus the matching-identity post with authorship read-back.Supported Plane API facts (v1.3.1 = deployed pin, read-only probes)
GET /api/v1/users/me/— API-root scoped. The workspace-scoped form/api/v1/workspaces/<slug>/users/me/answers404on v1.3.1 (measured live), so this call cannot useapiBase();request()gained an explicit-base sibling.UserLiteSerializer:id, first_name, last_name, email, avatar, avatar_url, display_name. Plane has no login field, so an expectation may name the id, email, display name, orFirst Last.IssueCommentCreateSerializermarksactorread-only, the view savesactor=request.user.comment_htmlis re-serialized through lxml on write, so a byte-exact body comparison would be unsound —actoris what a read-back can trust.Guard
assertCommentIdentity(plane/api.ts) reads the current user and refuses before any POST, never throwing:users/mefails or answers without an id);expected_identityarg, elsePLANE_EXPECTED_IDENTITY);postVerifiedCommentrequires a branded proof minted by the assertion (a hand-rolled{ ok: true }cannot reach the POST), then reads the comment back by id and requires the storedactorto equal the token's user id; a mismatch is reported loudly with the comment id, because at that point the write already happened.Ordinary comments still post without a declaration — the identity is always read and reported, never guessed — because Plane's Done gate consumes the verification marker rather than the author, unlike the Forgejo landing lock.
Both comment write paths (tool + sidebar) go through the pair. No historical comment is edited or deleted; no protected database touched; no live comment write was performed during development.
Verification
planesuite 36/36 (17 new inplane/tests/comment-identity.test.ts),tsc --noEmitclean.plane/**ordocs/plane.md.plane/api.ts,plane/index.ts,plane/tests/fake.ts,plane/tests/comment-identity.test.ts,docs/plane.md.`plane_comment` (and the sidebar's add-comment) posted with whatever token was in PLANE_API_KEY and never asked the API whose token that was. Authorship is not decoration here: the pipeline writes verification records (HOMELAB-1679) and stage evidence to tickets, so a record attributed to an unknown user is a record nobody can rely on. This is the same structural gap HOMELAB-2527 fixed for Forgejo, on the Plane side; no live misattribution was ever demonstrated. Measured red-first, against the unmodified tool (fake API, no network), all three cases POSTed anyway: x declared identity mismatch must refuse with ZERO writes x unreadable identity must refuse with ZERO writes x a verification record with no declared identity must refuse Supported Plane API facts this rests on (v1.3.1, the deployed pin): - The current-user read is `GET /api/v1/users/me/`, API-ROOT scoped. The workspace-scoped form `/api/v1/workspaces/<slug>/users/me/` answers 404 on v1.3.1, so `request()` gained an explicit-base sibling and the call does not use `apiBase()`. Response is UserLiteSerializer — `id, first_name, last_name, email, avatar, avatar_url, display_name`; Plane has no login field, hence the expectation may name id/email/display name. - The author is server-assigned: IssueCommentCreateSerializer marks `actor` read-only and the view saves `actor=request.user`, so `actor` is what a read-back can trust. `comment_html` is re-serialized through lxml on write, so a byte-exact body comparison would be unsound and is not used. The guard (plane/api.ts): `assertCommentIdentity` reads the current user and refuses BEFORE any POST — never throws — when the identity is unreadable, when it contradicts a declared expectation (`expected_identity` argument, else PLANE_EXPECTED_IDENTITY), or when a verification record carries no declared identity at all. `postVerifiedComment` requires a branded proof minted by the assertion (a hand-rolled `{ ok: true }` cannot reach the POST), then reads the comment back by id and requires the stored `actor` to be the token's user; a mismatch is reported loudly with the comment id, because the write already happened. Ordinary comments keep posting without a declaration — the identity is always read and reported, never guessed — since Plane's Done gate consumes the verification marker rather than the author, unlike the Forgejo lock. Both comment write paths (tool and sidebar) go through the pair. No historical comment is edited or deleted; no protected database is touched. Tests: 36/36 (17 new), tsc clean.CI green on head
7c8d7a633b(run #174): all 14 jobs pass, includingplane(1m28s) andpr-title(8s).Local verification on the same tree:
planesuite 36/36 (19 closure + 17 new identity tests)tsc --noEmitclean (strict)Ready for independent review. No self-review, verdicts, or merge from the author.
Verdict: APPROVED
model: omniroute/deepseek-flash
head:
7c8d7a633bGENERAL review — HOMELAB-2529 / pi-extensions #77
Verdict: APPROVED. Model
omniroute/deepseek-flash, general reviewer, fresh context.Reviewed exact head
7c8d7a633b1fd07a720dd4f4c4e56cf48f7b2a12(re-fetched live; unchanged).CI #174 at that sha: 14/14 jobs pass, including
plane. No blockers.Contract
HOMELAB-2529 asked for a read-only characterization first, then — only if confirmed — an
expected-identity assertion before comment writes, fake-API red-first, zero writes on
mismatch/unreadable identity, supported Plane APIs only. The diff is
plane/api.ts,plane/index.ts,plane/tests/fake.ts,plane/tests/comment-identity.test.tsanddocs/plane.md(+727/−8), inside the ticket's declared file set.Independently reproduced
plane36/36 (17 new, 19 closure);tsc --noEmitclean./api/v1/users/me/→ 200;the workspace-scoped twin → 404, so
apiBase()genuinely cannot carry this call and thenew
apiRoot()is the necessary base rather than a stylistic choice.GET .../comments/<id>/→ 200 returningactorandcomment_html, so the read-backleg rests on a real endpoint, not an assumption. Every listed row has
actor == created_by.origin/main'splane/index.tscalledcreateCommentdirectly on both write paths with no identity readanywhere on the path.
Attack surface, item by item
requestAt()takes the base explicitly andrequest()delegateswith
apiBase(), so every pre-existing call site composes a byte-identical URL (sametrailing-slash rule, same params handling). Minimal and correct.
PLANE_EXPECTED_IDENTITY,blank collapses to "no expectation" so an empty declaration cannot become either
match-nobody or match-anybody; matching covers id, email, display name and
First Lastcase-insensitively, empty never matches. Plane has no login field, so that is the right key
set.
predicate is the very
hasVerificationMarkerthe HOMELAB-1679 Done gate consumes, so guardand gate cannot disagree about what evidence is. This is a faithful mirror of the merged
Forgejo leg from HOMELAB-2527, not a novel rule invented on this PR.
createCommenton bothpaths; the tests pin zero comment POSTs, and for unreadable identity zero calls to any
comment path at all. Refusals are returned rather than thrown, so the reason reaches the
caller intact.
postVerifiedCommentdemands bothok === trueand the symbol, and a hand-rolled object is rejected with zero fetch calls.
actormust equal the token's user id; a mismatch throwsnaming the comment id and stating the write already happened, which is the honest failure
mode for a post-write discovery.
is the correct call given Plane re-serializes
comment_htmlon write.as
Comment failed: …. No unguarded second write path remains;createCommentis no longerimported by
index.ts.requestandcreateCommentsignatures preserved, tooldetailsstill carries the stored comment withldAttributionadditive, prompt guidelineadditive. No historical comment edited or deleted, no protected data touched.
Non-blocking observations
Plane API GET …error instead ofthe "the write may have landed — reconcile" wording the other two post-write legs use, so
an agent could retry and duplicate. The endpoint answers 200 today, so the exposure is
narrow, but wrapping it would make the trio consistent.
data-ld-verification(no heading) could pass the pre-POST check and then throw after asuccessful write, and re-posting would fail identically. The canonical
<h2>Verification</h2>form is demonstrably preserved (observed in stored rows); theattribute form is not observed anywhere sampled. The docs do not overclaim it and the
guideline leads with the heading, so this is a sharp edge worth remembering rather than a
defect on this PR.
require a declared identity and
PLANE_EXPECTED_IDENTITYis not set in this environment,so agents must pass
expected_identityper call. That is the ticket's intent and therefusal explains itself, but
.pi/skills/homelab-ticketdoes not yet mention it — the firstagent to hit the refusal should not read it as a Plane outage.
without
bodyand then posts verification HTML would bypass the undeclared-evidence leg.Both in-repo write paths pass
body, so this is hardening rather than a hole in theshipped path.
Nothing above gates landing. The change is the minimal correct guard for the confirmed
structural gap, its evidence is reproducible, and the refusals are all on the safe side of the
write.
Verdict: APPROVED
model: omniroute/k3.1-pro
head:
7c8d7a633btier: premium
I independently reviewed the complete Plane-scoped change at the exact live head and approve it. No open blockers.
The API-root current-user read is correctly separated from workspace requests, and existing request and comment-call signatures remain compatible. Explicit expectations override the environment, blank declarations do not match, and verification-marked comments require a declared identity. Both the tool and sidebar writes assert identity before posting. Mismatch, unreadable identity, and undeclared verification therefore perform zero comment writes. The private proof brand prevents a hand-rolled assertion from reaching the posting helper.
The post-write check reads the stored comment and requires its actor to equal the current-user id. It does not compare sanitized HTML byte-for-byte; it checks only that the verification marker survives, using the same marker predicate as the closure gate. Tool results retain the stored comment and add attribution details.
I reran the Plane suite independently: 36 of 36 tests passed, including all 17 new identity tests, and strict TypeScript checking was clean. CI run 174 is green in all 14 jobs at this head.
Non-blocking: if the read-back request itself fails after a successful POST, the raw GET error does not explicitly warn that the comment may already have landed. Missing ids, actor mismatches, and marker loss do provide that warning. Clearer reconciliation wording for transport failure would be a useful follow-up, but this does not bypass the required pre-write guard.