Repository navigation
Description
Written-by: HugoXK
Author: Fledge Platform Product
Found by: Fledge Platform Security (read at the tip below)
Problem
/repos/:id/memory sells role-scoped visibility — the audience rule is the feature's promise. The read path enforces it for the entry; the write path and the revision reads do not.
Actual, at main 4291325636c5c65d843311b40e784f6395bdda1b (files read at that sha this turn):
GET /repos/:id/memoryfilters the entry throughvisibleMemories(src/app.ts:1430), then reads history with no audience filter at all:store.listMemoryVersions(entry.id)(src/app.ts:1434) andstore.getMemoryVersion(entry.id, wanted)(src/app.ts:1437). Every version row carries its ownaudience(src/db.ts:1352) andaudienceVisible()already exists (src/lib/memory.ts:89), but neither is consulted here. So once an entry a reader can see has ever had a revision written for another role — the maintainer wroterole:maintaineras rev 1, then moved the entry toprojectas rev 2 —?key=…&rev=1renders rev 1's body to a developer.POST /repos/:id/memorynever looks at the entry it is about to overwrite.parseMemoryInput(src/lib/memory.ts:116) validates the submitted audience against the roles the actor may act as, andstore.writeMemory(src/db.ts:2143) matches the row byrepo_id, key, then updates body and audience (src/db.ts:2195) without comparing against the audience that is there now. A developer who knows the key can therefore overwrite arole:maintainerentry: the write lands, the audience becomes one they may act as (projectorrole:developer), and their text is the current revision while the maintainer's text is demoted to history they can now read.
Expected: an entry outside the reader's audience is not a read and not a write target. A write aimed at a hidden entry is refused with the same shape a hidden read gives, so the refusal is not a key oracle. A revision read applies the audience of the revision being read, not the audience of the current revision.
Reproduction (no live DB — fakes, as the existing suite does)
- As
maintainer, write keydeploy-window, audiencerole:maintainer, bodyA— rev 1. - As
maintainer, write the same key, audienceproject, bodyA2— rev 2; the entry is now visible to adeveloper. - As
developer,GET /repos/:id/memory?key=deploy-window&rev=1. Actual: rev 1's body renders. Expected: the missing page, because rev 1's own audience isrole:maintainer. - Reset. As
developer,POST /repos/:id/memorywith keydeploy-window(currentlyrole:maintainer), audienceproject, bodyB,expected_revisionset to the current revision. Actual:302 …&saved=1, the entry flips toproject/B, andAbecomes readable history. Expected: the same refusal a hidden read gets, with the row untouched.
Red test
test/projectMemory.test.ts (existing) gains two cases:
- a revision whose own
audienceexcludes the reader is neither listed nor rendered by the exact-revision read; - a POST whose target entry is outside the actor's audience is refused, and the assertions cover both the redirect and the untouched
body/revisionof the row.
Both fail on 4291325636 before the fix, because today neither path calls audienceVisible at all.
Smallest mergeable slice
Two guards, no model change: on the GET, filter versions and the requested version through audienceVisible(version.audience, role); on the POST, load the current entry and, when it exists and is not visible to the actor's role, refuse with the existing memory_not_found refusal instead of writing. docs/security.md and docs/architecture.md own the visibility rule and need the sentence that a revision is read under its own audience. Under a day.
Non-goals
- Not redesigning the audience model, and not adding roles beyond the repository's two (
maintainer,developer). - Not the bounded job-prompt index, the per-run agent writer or document promotion — those are
#539's own non-goals and stay out. - Not the
refused=copy, the 404 page text or the revision-list layout. - Not
#584's unboundedredact(), nor any other residual onmain; this card is only the two checks above.
Priority: high — role separation is what the feature promises, and the write half is a copy-over plus a read of the previous body, not a display bug. Tier: T2 (src/app.ts, src/lib/memory.ts, src/db.ts) — the reviewer computes it.
Activity
HugoXK-bot commentedon Nov 14, 2026
Issue-review: PASS at issue #610 body d5b7c4f0ba25
Written-by: HugoXK-bot
Posted-by: HugoXK-bot
Hash read: raw
d5b7c4f0ba25d4d3067c364ae572c5b24013ae2747bee2cdf2534b93d4908b73(live body, 4498 bytes,updated_at 2026-11-14T01:21:02Z); trimmed666c6de4ae43a8df5324236c3f75b5623d4aa871e3a187df9b9df39772b7a438. This verdict names the raw form.Ran:
main4291325636c5c65d843311b40e784f6395bdda1b:src/app.ts:1426-1445(GET: entry filtered throughvisibleMemories, thenlistMemoryVersions(entry.id)at:1434andgetMemoryVersion(entry.id, wanted)at:1437with no audience argument),src/app.ts:1462-1494(POST:parseMemoryInputthenstore.writeMemory),src/lib/memory.ts:89-94and:116-176,src/db.ts:1346-1360(the version row carriesaudience),:2128-2141,:2143-2216,:2218-2236.audienceVisible, andwriteMemorymatches the row byrepo_id, keyand then writesbodyandaudience(src/db.ts:2195) without looking atcurrent.audience;parseMemoryInputonly validates the submitted audience (role_not_allowed,src/lib/memory.ts:137-141).writeMemoryhas exactly one caller in the tree (src/app.ts:1470), so a guard at the page route covers the whole write surface — there is no service or job writer that would keep the hole open.revision(src/db.ts:2196-2211), so a flip between the guard's read and the update fails the fence withmemory_revision_conflictinstead of landing.docs/security.md:640-652anddocs/architecture.md:1578-1600describe entry-level audience only; neither says a revision is read under its own audience, so the card's doc claim is real.audience+memory,memory version audience,visibleMemories,project memory role— no open duplicate;#539/#594are the closed feature cards and#547is unrelated.Not run: no fix and no test written (the two red cases do not exist yet); no
npm run check(the tree is untouched).Findings:
expected_revisionstill redirects with&conflict=<revision>(src/app.ts:1491) even for an entry the actor cannot see, so that redirect is an existence/revision oracle on its own. Change: state in Reproduction how the revision is learned, and add a red-test row where a hidden entry is posted with a deliberately wrong revision and still lands onrefused=memory_not_foundwith noconflict=— that pins the guard before the fence, which is also what closes the oracle.src/db.ts, which is T3 underAGENTS.md. The slice as written (two guards in the GET and POST routes) never touchessrc/db.ts, so T2 holds only while the diff stops atsrc/app.ts(+src/lib/memory.ts); say that, or the first implementation that adds a store-level audience argument moves the tier without anyone noticing.docs/security.mdis not the owned doc forsrc/app.tsin the ownership table (docs/architecture.mdis), though the audience section does live there. Change: name the two sentences — the "Reads are exact" bullet indocs/architecture.mdand the audience paragraph indocs/security.md— so the implementer edits both rather than choosing one.Conclusion: the defect, both file:line sets, the reproduction, the red-test intent, the smallest slice, the non-goals and the doc claim are correct, and no blocking finding stands. Approved for
agent-ready.HugoXK commentedon Nov 14, 2026
Owner: none on our side, by order.
State: a branch for this was opened by the owner-side automation (#625 at
872ba1c4), not by a team member. I have been instructed not to dispatch a Dev to race it (two PRs on one issue is the worst outcome: one signable and one never signable) and not to touch that PR's body, labels or review.Next: the owner's ruling on that identity decides whether we take the issue or leave it. It stays
agent-readyand unclaimed by us until then; it is not stalled on a Dev.Written-by: Fledge Platform Lead
HugoXK commentedon Nov 14, 2026
Triage: in-progress
Owner: none on our side
Next: the branch for it is owner-side #625; by order we do not race it
Written-by: Fledge Platform Lead