SECURITY: Scope change-feed cursors to one User #334

Closed
opened 2026-09-28 11:48:29 +00:00 by kayg · 4 comments
Owner

Evidence

crates/plugins/files/migrations/0001_files.sql defines files_events.id as a global INTEGER PRIMARY KEY AUTOINCREMENT. crates/plugins/files/migrations/0006_change_feed.sql defines files_change_feed.cursor the same way. These tables hold rows for all Users.

The Files API filters returned rows by user_id, but exposes these global sequence values: /api/v1/files/changes returns cursor and /changes/head returns the maximum cursor for the caller; /api/v1/files/events uses the global event ID as the SSE Last-Event-ID. The source even documents that per-User feeds can have gaps because the SQLite sequence is shared.

A User who makes two changes can compare their cursor values. The gap includes events inserted for other Users during that interval. This does not reveal another User's row, path or content, but it reveals aggregate cross-User file activity and timing.

Required change

Decide whether aggregate User activity is private. If it is, replace the global sequence with per-User cursor values or opaque cursors that do not expose global insert counts. Migrate retained feed rows and floors safely, and keep replay correct across reconnects, retention, startup reconcile, and Agent undo. Add a two-User API regression that inserts changes for one User between two changes for the other and proves the second User cannot infer the intervening count.

## Evidence `crates/plugins/files/migrations/0001_files.sql` defines `files_events.id` as a global `INTEGER PRIMARY KEY AUTOINCREMENT`. `crates/plugins/files/migrations/0006_change_feed.sql` defines `files_change_feed.cursor` the same way. These tables hold rows for all Users. The Files API filters returned rows by `user_id`, but exposes these global sequence values: `/api/v1/files/changes` returns `cursor` and `/changes/head` returns the maximum cursor for the caller; `/api/v1/files/events` uses the global event ID as the SSE `Last-Event-ID`. The source even documents that per-User feeds can have gaps because the SQLite sequence is shared. A User who makes two changes can compare their cursor values. The gap includes events inserted for other Users during that interval. This does not reveal another User's row, path or content, but it reveals aggregate cross-User file activity and timing. ## Required change Decide whether aggregate User activity is private. If it is, replace the global sequence with per-User cursor values or opaque cursors that do not expose global insert counts. Migrate retained feed rows and floors safely, and keep replay correct across reconnects, retention, startup reconcile, and Agent undo. Add a two-User API regression that inserts changes for one User between two changes for the other and proves the second User cannot infer the intervening count.
Author
Owner

Orchestrator decision (security default, 2026-09-28): aggregate activity is private. Replace exposed global sequences with opaque, per-User cursors (e.g. a per-User monotonic counter, or an encrypted/HMAC'd wrapper over the internal row id that is only meaningful to that User). Migrate retained rows and floors; keep replay correct across reconnects, retention, startup reconcile and Agent undo; add the two-User regression from this issue.

Orchestrator decision (security default, 2026-09-28): **aggregate activity is private.** Replace exposed global sequences with **opaque, per-User cursors** (e.g. a per-User monotonic counter, or an encrypted/HMAC'd wrapper over the internal row id that is only meaningful to that User). Migrate retained rows and floors; keep replay correct across reconnects, retention, startup reconcile and Agent undo; add the two-User regression from this issue.
Author
Owner

Started work on job/cursors, based on dev at ba7104f289603a58d99b81500f4ffe12e0010666.

Started work on `job/cursors`, based on `dev` at `ba7104f289603a58d99b81500f4ffe12e0010666`.
Author
Owner

Completed Forgejo #334 on job/cursors.

Change

Added per-User monotonic cursors for the Files change feed and event stream. API pages, head responses, SSE replay IDs, feed floors, wakeups, Agent undo checks, and mutation tracking now use the caller's cursor. Existing global row IDs remain internal. Migration 0015 seeds each User's cursor from retained positions and floors, then advances counters only for that User; deleting a User also removes its counter.

Added regressions for interleaved two-User feed/SSE activity and for migration retention of existing positions and floors.

Files

  • crates/plugins/files/migrations/0015_user_scoped_cursors.sql
  • crates/plugins/files/src/agent_undo.rs
  • crates/plugins/files/src/lib.rs
  • crates/plugins/files/src/uploads.rs
  • crates/plugins/ai/src/lib.rs
  • crates/plugins/ai/src/routes.rs
  • crates/calternal-sync/feed_rescan_campaign.py
  • crates/plugins/files/kill_stress.py
  • tests/adversarial/attack2.py

Branch

Feature commit: e33be8a7 (Scope Files cursors per User to protect activity privacy).

Merged dev once before final gates. Head: 9c07fea231d50889b4dffce21eb79ecc10473c1b. Pushed job/cursors; push returned Everything up-to-date.

Gates

  • cargo fmt --check: exit 0, no output.
  • cargo clippy --all-targets -- -D warnings: passed. Output:
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 51m 38s
    
  • cargo test --workspace: failed on the existing load-sensitive collaboration timing test. Relevant output:
    10,000-block collaboration phases: parse=1.441311437s, Yrs=641.467919ms, block-index=52.087301ms, first-sync=98.610179ms (616204 bytes), snapshot=118.971461ms (616199 bytes), total=2.352448297s
    10,000-block open, first sync and snapshot took 2.352448297s
    test result: FAILED. 14 passed; 1 failed
    error: test failed, to rerun pass `-p calternal-collab --lib`
    
    The existing expectation was kept. Targeted tests passed before the merge: calternal-plugin-files (122 passed) and calternal-plugin-ai (12 passed).
  • bun run check: passed. Output:
    Loading svelte-check in workspace: /home/kayg/Developer/calternal-wt/cursors/apps/web
    Getting Svelte diagnostics...
    
    svelte-check found 0 errors and 0 warnings
    
  • bun run test: passed. Output:
     Test Files  112 passed (112)
          Tests  727 passed (727)
       Start at  19:07:01
       Duration  95.48s (transform 57%, environment 16%, import 15%, tests 9%, setup 3%)
    

Adversarial round: ADVERSARIAL_ROUND2_ONLY=1 ROUND2_SECTIONS=feed tests/adversarial/run.sh. Output: server alive at end: True, ==== ROUND 2 FINDINGS 0, ==== ROUND 2 SLOW 0.

Decisions and known gap

The design specified per-User cursors but left their representation and migration behavior open. I used monotonic integer cursors scoped to each User, seeded from retained legacy positions/floors so existing resume points remain valid; the global sequence stays internal.

Known gap: the full workspace test command is red only on ten_thousand_markdown_blocks_open_and_sync_within_two_seconds, which measured 2.352448297s against its 2s threshold under shared-host load. No existing test expectation was changed.

Cleanup: cargo clean reported Removed 17618 files, 15.1GiB total; apps/web/build was removed.

Completed Forgejo #334 on `job/cursors`. ## Change Added per-User monotonic cursors for the Files change feed and event stream. API pages, head responses, SSE replay IDs, feed floors, wakeups, Agent undo checks, and mutation tracking now use the caller's cursor. Existing global row IDs remain internal. Migration 0015 seeds each User's cursor from retained positions and floors, then advances counters only for that User; deleting a User also removes its counter. Added regressions for interleaved two-User feed/SSE activity and for migration retention of existing positions and floors. ## Files - `crates/plugins/files/migrations/0015_user_scoped_cursors.sql` - `crates/plugins/files/src/agent_undo.rs` - `crates/plugins/files/src/lib.rs` - `crates/plugins/files/src/uploads.rs` - `crates/plugins/ai/src/lib.rs` - `crates/plugins/ai/src/routes.rs` - `crates/calternal-sync/feed_rescan_campaign.py` - `crates/plugins/files/kill_stress.py` - `tests/adversarial/attack2.py` ## Branch Feature commit: `e33be8a7` (`Scope Files cursors per User to protect activity privacy`). Merged `dev` once before final gates. Head: `9c07fea231d50889b4dffce21eb79ecc10473c1b`. Pushed `job/cursors`; push returned `Everything up-to-date`. ## Gates - `cargo fmt --check`: exit 0, no output. - `cargo clippy --all-targets -- -D warnings`: passed. Output: ``` Finished `dev` profile [unoptimized + debuginfo] target(s) in 51m 38s ``` - `cargo test --workspace`: failed on the existing load-sensitive collaboration timing test. Relevant output: ``` 10,000-block collaboration phases: parse=1.441311437s, Yrs=641.467919ms, block-index=52.087301ms, first-sync=98.610179ms (616204 bytes), snapshot=118.971461ms (616199 bytes), total=2.352448297s 10,000-block open, first sync and snapshot took 2.352448297s test result: FAILED. 14 passed; 1 failed error: test failed, to rerun pass `-p calternal-collab --lib` ``` The existing expectation was kept. Targeted tests passed before the merge: `calternal-plugin-files` (122 passed) and `calternal-plugin-ai` (12 passed). - `bun run check`: passed. Output: ``` Loading svelte-check in workspace: /home/kayg/Developer/calternal-wt/cursors/apps/web Getting Svelte diagnostics... svelte-check found 0 errors and 0 warnings ``` - `bun run test`: passed. Output: ``` Test Files 112 passed (112) Tests 727 passed (727) Start at 19:07:01 Duration 95.48s (transform 57%, environment 16%, import 15%, tests 9%, setup 3%) ``` Adversarial round: `ADVERSARIAL_ROUND2_ONLY=1 ROUND2_SECTIONS=feed tests/adversarial/run.sh`. Output: `server alive at end: True`, `==== ROUND 2 FINDINGS 0`, `==== ROUND 2 SLOW 0`. ## Decisions and known gap The design specified per-User cursors but left their representation and migration behavior open. I used monotonic integer cursors scoped to each User, seeded from retained legacy positions/floors so existing resume points remain valid; the global sequence stays internal. Known gap: the full workspace test command is red only on `ten_thousand_markdown_blocks_open_and_sync_within_two_seconds`, which measured 2.352448297s against its 2s threshold under shared-host load. No existing test expectation was changed. Cleanup: `cargo clean` reported `Removed 17618 files, 15.1GiB total`; `apps/web/build` was removed.
Author
Owner

Merged into dev by Claude (after review). Legacy cursors stay valid: per-User counters continue above each User's old high-water mark. Files plugin tests on merged dev: 122 passed. The red collab timing test (2.35 s vs 2 s) is load-confounded and tracked elsewhere.

Merged into dev by Claude (after review). Legacy cursors stay valid: per-User counters continue above each User's old high-water mark. Files plugin tests on merged dev: 122 passed. The red collab timing test (2.35 s vs 2 s) is load-confounded and tracked elsewhere.
kayg closed this issue 2026-09-28 17:20:48 +00:00
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
kayg/calternal#334
No description provided.