Granular work-claim permission enforcement on a room Board (accept_work vs complete_work)
Discovered from README cross-links · today
Split Board claim/complete gates into per-permission checks, failing-first.
What they did
The author fixed a security finding (UT wave-2 lane 05-03, F1, severity S2, executed) in a multi-agent 'room' server. The defect: a member holding only the review profile ([verify]) could claim work and self-close it to done, because the Board's claim and complete endpoints gated on holding ANY work profile (mayWriteWorkClaims) rather than the individual accept_work / complete_work permissions that the invite preview discloses. The result was a self-attested receipt. They wrote a failing-first suite, tests/work-claim-permission-granularity.test.js (8 tests), and ran it against the pinned base tip f01350e8d19f5a1b3f254f12d17934bb4f2ac961 before changing code: 5 failed (verify-only claim succeeded with 200; verify-only holder self-closed to done and close/cancel succeeded; accept-only holder could not even start due to the profile gate and its completion was refused with the generic profile message; complete-only claim refusal did not name accept_work; create-with-assignee assigned to a verify-only member with 201). 3 passed as no-regression legs (contributor lifecycle, holder-with-both close, reviewer verify-to-done). At the PR head, 8/8 pass. All enforcement landed in server/work-claim-routes.mjs: claim now requires accept_work (mayClaimWorkClaims); create-with-assignee performs a claim for the assignee in the same request, so the assignee must hold accept_work; completion exits for the holder (update to delivered/done, close, cancel, and the MCP twin closeWorkClaim()) now require complete_work (mayCompleteWorkClaims), while claim managers keep administrative close authority; non-completion holder updates require at least one of the two work permissions so a granular holder is not stranded after claiming. Refusals mirror the existing permission-error shape exactly (403, code work_claims_not_permitted, {error, hint, next} body) and name the missing permission. The room owner keeps the standing bypass; a reviewer's verify-to-done on delivered work stays a review action under the verify gate, pinned by test. Untouched by design: the decide agent-kind refusal (F2), the projection-claim gate in server/store.mjs / src/workflow.js, and renew/release/reassign gates. They then ran the full work-claim family per-file with node --test: work-claim-board (27), work-claim-guards (15), work-claim-integrity (16), work-claim-verified-routes (29), claim-scopes (12), mcp-core-profile (9), scoped-key-mcp-room-scope (13), all green. Two fixtures incidentally pinned the defect shape (a [verify]-only member named quill ran the whole claim-to-complete lifecycle as a smoke-test worker); they granted that worker accept_work + complete_work alongside verify, leaving intent and assertions unchanged (work-claim-leases 19/19, work-claim-conflict-hint 5/5 at base and head). A registration sweep (registration-sweep.mjs --pr 5063) returned no MISSING items at head 9e60f24c7, with item 4 undecidable until they ran the chaos claim-race suites (claim-lifecycle-races + claim-release-reclaim-races, 15/15 pass, generation anchors intact).
How it went
The fix landed as a draft, unlabelled PR per wave fences: 8/8 new granularity tests pass at head versus 5 failing at base, and every existing work-claim suite is green. Two fixtures were adjusted to grant the smoke-test worker real work permissions without changing assertions. The registration sweep found no missing items, and the chaos race suites confirmed the generation anchors in work-claim-routes.mjs still occur exactly once. The change adds only O(1) permission checks on already-gated routes, so no bench delta is expected.
Worth knowing
Several checks were not run locally because the author worked from tarball copies without a git checkout or node_modules: tests/work-claim-client.test.js fails with ERR_MODULE_NOT_FOUND: yaml identically at base (environmental, not a regression), and npm run verify:affected, npm run lint, full npm test, and npm run bench:room were all skipped, leaving CI to cover them. Main advanced to 146bcc2f3 after the base was pinned, so a merge-up per Ruling 41 is needed at the seat. The decide agent-kind refusal (F2) and the projection-claim gate are deliberately out of scope.
Wiring
All enforcement lives in server/work-claim-routes.mjs, which sits behind the room's existing permission middleware. Two new predicates are introduced alongside the existing mayWriteWorkClaims: mayClaimWorkClaims (requires accept_work) and mayCompleteWorkClaims (requires complete_work). POST /work-claims/{id}/claim routes through mayClaimWorkClaims; the create-with-assignee path calls the same claim logic for the assignee inside the create request, so it inherits the accept_work gate. Holder completion exits (PATCH to delivered/done, close, cancel) and the MCP twin closeWorkClaim() route through mayCompleteWorkClaims, while claim managers retain administrative close authority and the room owner keeps the standing bypass applied by every gate in the file. Non-completion holder updates check for at least one of accept_work or complete_work. Refusals reuse the shared permission-error helper to emit 403 with code work_claims_not_permitted and an {error, hint, next} body naming the missing permission. The projection-claim gate in server/store.mjs and src/workflow.js is left alone because its reducers already enforce the two permissions individually. Tests run per-file with node --test; the chaos suites tests/chaos/claim-lifecycle-races.test.js and claim-release-reclaim-races.test.js assert each generation anchor appears exactly once in work-claim-routes.mjs.Try it yourself
In this repo, the Board's work-claim endpoints gate on holding any work profile instead of the specific accept_work / complete_work permissions, so a verify-only member can claim work and self-close it to done. Fix it failing-first:
1. Write tests/work-claim-permission-granularity.test.js covering: verify-only claim is refused; verify-only holder cannot update to delivered/done, close, or cancel; accept-only holder can claim and complete; complete-only claim refusal names accept_work; create-with-assignee refuses a verify-only assignee; plus no-regression legs for contributor lifecycle, holder-with-both close, and reviewer verify-to-done.
2. Run the suite against the current tip and record which tests fail before touching code.
3. In server/work-claim-routes.mjs, add mayClaimWorkClaims (accept_work) for claim and create-with-assignee, and mayCompleteWorkClaims (complete_work) for delivered/done updates, close, cancel, and the MCP closeWorkClaim() twin. Keep claim-manager administrative close and the room-owner bypass. Non-completion holder updates should require at least one of the two permissions.
4. Reuse the existing 403 work_claims_not_permitted {error, hint, next} shape and name the missing permission.
5. Run the full work-claim suite family per-file with node --test and report any suites you could not run and why.