fix: rotate cloud sessions on project switch#145
Conversation
Sigilix OverviewEffort: 4/5 (large) Quality gates
Summary — latest pushIntroduces project-scoped cloud session rotation so that switching the active project finalizes the current cloud session and starts a new one, preserving local conversation history while correctly scoping cloud uploads and token totals. The implementation adds historyStart and tokenStart tracking to CloudSessionTracker to slice the local history for each project's cloud upload. A P1 logic bug exists where SwitchProject resets the tracker state before the newly started session's fields are populated, creating a window where a concurrent CompleteCurrent call would operate on zeroed bounds. Important files
Confidence: 2/5The state-reset race in SwitchProject leaves the tracker in a vulnerable intermediate state that could cause incorrect cloud uploads or out-of-bounds history slicing under concurrent access.
Suggested labels:
|
✅ QualityMax Pipeline
Powered by QualityMax — AI-Powered Test Automation |
| func (t *CloudSessionTracker) CompleteCurrent(client *api.APIClient, totalTokens int, messages []api.Message) { | ||
| start := t.historyStart | ||
| if start < 0 || start > len(messages) { | ||
| start = 0 | ||
| } |
There was a problem hiding this comment.
Out-of-range historyStart uploads full replacement history to wrong cloud session
When historyStart exceeds the current len(messages) (for example, after /load replaces ag.History with a shorter saved session), CompleteCurrent resets start to 0 and uploads messages[0:]. This violates the project-scoping invariant: messages that predate the active cloud session are uploaded to the finalizing session. Cap start at len(messages) instead so the slice is empty when local history has been truncated.
Example:
historyStart = 5 after a cloud session began with 5 local messages
after /load, len(messages) = 2
actual: projectMessages = messages[0:] (uploads 2 loaded messages to the old project's cloud session)
expected: projectMessages = messages[5:] = nil (uploads nothing)
Current:
func (t *CloudSessionTracker) CompleteCurrent(client *api.APIClient, totalTokens int, messages []api.Message) {
start := t.historyStart
if start < 0 || start > len(messages) {
start = 0
}Proposed:
start := t.historyStart
if start < 0 {
start = 0
}
if start > len(messages) {
start = len(messages)
}
projectMessages := messages[start:]| func (t *CloudSessionTracker) CompleteCurrent(client *api.APIClient, totalTokens int, messages []api.Message) { | |
| start := t.historyStart | |
| if start < 0 || start > len(messages) { | |
| start = 0 | |
| } | |
| start := t.historyStart | |
| if start < 0 { | |
| start = 0 | |
| } | |
| if start > len(messages) { | |
| start = len(messages) | |
| } | |
| projectMessages := messages[start:] |
More Info
- Threat model: A user who starts a cloud-tracked REPL, then
/loads a shorter saved session, and later exits (or switches projects again) causes the finalizing cloud session to receive messages from the loaded session. Those messages may belong to a different project or an earlier conversation, leaking context and inflating the event count/token totals for the wrong project. - Specific code citations:
internal/session/cloud_session.golines 117-121:CompleteCurrentslicesmessages[start:]after thestart > len(messages)guard resetsstartto0. The retrieval snapshot forinternal/repl/repl.goshows/loadreplacingag.Historywithout touching the tracker. - Existing protections: The
projectTokens < 0clamp prevents negative token totals, but there is no equivalent guard for the history slice.SwitchProjectandStartWithHistoryonly sethistoryStarttolen(messages)at session creation; neither/loadnor any other visible path updates it whenag.Historyis replaced. - Proposed mitigation: Split the bounds check so
start > len(messages)capsstartatlen(messages), producing an empty slice when local history has been truncated. Optionally resethistoryStart/tokenStartwhen/loadreplaces history, but the local cap is the minimal fix. - Alternative mitigations considered: Resetting the tracker on
/loadis broader and would lose the in-progress cloud session; capping the slice preserves the existing session while avoiding cross-project data leakage. - Severity calibration: Score 4: the failure path requires a
/loadafter cloud session start, but when it happens it silently uploads wrong messages to the wrong project. It is not a crash but a data-scoping/information-leak bug.
Prompt To Fix With AI
This is a comment left during a code review.
Path: internal/session/cloud_session.go
Line: 117-121
Comment:
**Out-of-range historyStart uploads full replacement history to wrong cloud session**
When `historyStart` exceeds the current `len(messages)` (for example, after `/load` replaces `ag.History` with a shorter saved session), `CompleteCurrent` resets `start` to `0` and uploads `messages[0:]`. This violates the project-scoping invariant: messages that predate the active cloud session are uploaded to the finalizing session. Cap `start` at `len(messages)` instead so the slice is empty when local history has been truncated.
Example:
historyStart = 5 after a cloud session began with 5 local messages
after /load, len(messages) = 2
actual: projectMessages = messages[0:] (uploads 2 loaded messages to the old project's cloud session)
expected: projectMessages = messages[5:] = nil (uploads nothing)
Threat model:
A user who starts a cloud-tracked REPL, then `/load`s a shorter saved session, and later exits (or switches projects again) causes the finalizing cloud session to receive messages from the loaded session. Those messages may belong to a different project or an earlier conversation, leaking context and inflating the event count/token totals for the wrong project.
Specific code citations:
`internal/session/cloud_session.go` lines 117-121: `CompleteCurrent` slices `messages[start:]` after the `start > len(messages)` guard resets `start` to `0`. The retrieval snapshot for `internal/repl/repl.go` shows `/load` replacing `ag.History` without touching the tracker.
Existing protections:
The `projectTokens < 0` clamp prevents negative token totals, but there is no equivalent guard for the history slice. `SwitchProject` and `StartWithHistory` only set `historyStart` to `len(messages)` at session creation; neither `/load` nor any other visible path updates it when `ag.History` is replaced.
Proposed mitigation:
Split the bounds check so `start > len(messages)` caps `start` at `len(messages)`, producing an empty slice when local history has been truncated. Optionally reset `historyStart`/`tokenStart` when `/load` replaces history, but the local cap is the minimal fix.
Alternative mitigations considered:
Resetting the tracker on `/load` is broader and would lose the in-progress cloud session; capping the slice preserves the existing session while avoiding cross-project data leakage.
Severity calibration:
Score 4: the failure path requires a `/load` after cloud session start, but when it happens it silently uploads wrong messages to the wrong project. It is not a crash but a data-scoping/information-leak bug.
How can I resolve this? If you propose a fix, please make it concise.
When /project changes the active project, finalize the existing cloud session and immediately begin a new one for the selected project.
The local conversation is retained, but cloud history and token totals are scoped to the appropriate project.
Tests: