Skip to content

approvals: a refused save leaves a standing grant live in memory, so an unaccepted 'Always allow' answers the next card #2764

Closed

Description

@HugoXK

Implementation: FlyBot (ours) — PR follows within 24h. Do not pick up without a pickup-ok comment on this issue.

Written-by: FlyBot Lead
Claimed-by: FlyBot Lead
Claim-Id: ae56041a44c844e4b8683da11fdd30b3
Branch: dev/3-approval-grant-rollback
Base: f2ef4df
At: 2026-11-10T01:03:43Z

Implementation: FlyBot (ours) — PR follows within 24h. Do not pick up without a pickup-ok comment.

Written-by: FlyBot Lead
Bot: FlyBot Product

answerApproval with "Always allow" writes the standing grant into the in-memory state before it saves, so a save the store refuses leaves a grant the owner never accepted in force: the next card of that kind is answered allowed-once by itself, and any later successful write persists the grant to disk.

Where

core/src/host/approvals.js at f2ef4dfaccedd40315bd585474d0902118dc0ba8:

  • grantStanding (:132-149): target.standingApprovals = [...grants, grant] at :142, then await rt.save() at :143. A throw from save() leaves the assignment in place.
  • removeStandingApproval (:153-164): delete target.standingApprovals / target.standingApprovals = after at :160-161, then await rt.save() at :162. A throw leaves the removal in place, so a grant that is still on disk disappears from memory.
  • standingGrant (:102-109) only compares kind, and standingCovers (:117-133) calls it before a card is created, so an in-memory grant answers the next request with no card.

rt.save() throws for real, store-side reasons: core/src/host/store.js:1022 (Not saved: <temp> is not a plain file of its own, the temp-name sweep's own guard), :1029 (ELOCKLOST, another dsh took the lock over), and a full disk or a permission error on the stage/rename.

Reproduction

A probe with the real install() and the real handler, on a state whose temp name is a symlink so store.js:1022 refuses the write:

1. card opened: true | pending cards: 1 | answerable: true
   grant before answering: []
2. answerApproval rejected: Not saved: /home/u/.dsh/bot/state.json.4242.tmp is not a plain file of its own, so the Bot …
   cards still waiting: 1
   grant in memory after the failed save: ["bash:escalate:danger-full-access"]
3. next ask of the same kind answered: allowed-once
   pending cards now: 1

Step 2's first two lines are already right (the card stays pending and the error reaches the client). What is wrong is the third line of step 2 and step 3: the grant is live and answers the next card, and one later unrelated successful write puts it on disk.

Reach as reported: approval-answer is in the channel read set (core/src/host/channel.js:242), but the client's read-only window does not offer the decision (#2372/#2382), so the practical trigger is the owner answering a card in a group chat while the state write fails. Rare, and the consequence is a standing grant nobody accepted taking effect in the background — this is why it is filed as a defect and not as an enhancement.

Expected

The same rule this family already uses (#2761, tasks.js): snapshot, save, and only count the write as taken when it succeeds; on a refusal restore the snapshot and rethrow the store's own error.

  • grantStanding: no standingApprovals entry in memory after a refused save; the card stays pending; the next ask of the same kind still produces a card.
  • removeStandingApproval: a refused save leaves the grant exactly as it was in memory (still present) and rethrows; a successful save still removes it (and deletes the field when the list empties).
  • state.revision is not restored: store.js:1015 increments it before the write and a refused write does not rewind it.
  • A successful grant behaves exactly as today, including answering the other pending cards the new grant already covers (:146-148) — those answers must stay on the success path only.

Slices and red-first

  1. Bot scope (always-bot): with the store refusing the save, answerApproval throws, the card is still pending, bots.<id>.standingApprovals is absent/empty, and a second ask of the same kind opens a card (today: answered allowed-once).
  2. Room scope (always-room): the same shape on rooms.<id>.standingApprovals.
  3. removeStandingApproval refusal: throws and the grant is still there (today: gone from memory while still on disk).

Keep the fix inside approvals.js; no rt.mutate() change and no new store API. Do not change the approval card's shape, the allowed-once outcome or the room-vs-Bot scope rules.

Dedupe

Checked live main f2ef4dfa and the open/closed issue list: #2761 is the same defect shape but all five save points are in core/src/host/tasks.js; #1048 (closed) moved inline writers into rt.mutate(), which is itself change-then-save with no rollback and never covered these two calls; #608/#544 (closed) are the persistence feature and its first fix; no open issue covers a standing-approval rollback. Family rule: one issue, one file — so this is a separate issue after #2761, not folded into it.

Risk tier: T2 (approval persistence and the outcome a card answers with).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions