fix: keep system events from extending session resets#71845
Conversation
|
Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits. |
🔒 Aisle Security AnalysisWe found 4 potential security issue(s) in this PR:
1. 🟠 Client-controlled sessionId enables session fixation / transcript hijack in gateway agent handler
DescriptionIn
This makes Why this is a security issue (in multi-user / multi-tenant deployments):
Vulnerable code: const usableRequestedSessionId =
requestedSessionId && (!entry?.sessionId || canReuseSession)
? requestedSessionId
: undefined;
const sessionId = usableRequestedSessionId
? usableRequestedSessionId
: ((canReuseSession ? entry?.sessionId : undefined) ?? randomUUID());Even though session IDs are usually UUIDs, they can still be exposed through logs, client-side state, screenshots, shared links, or other leaks; once known, this becomes a direct conversation takeover primitive. RecommendationDo not allow unprivileged callers to choose the underlying transcript Options (choose one):
if (requestedSessionId && entry?.sessionId && requestedSessionId !== entry.sessionId) {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "sessionId does not match sessionKey"));
return;
}
const sessionId = entry?.sessionId ?? randomUUID();
Additionally, consider binding sessions to an authenticated principal (tenant/user) and enforcing authorization on any request that specifies a 2. 🟡 Session freshness bypass: /command override persistence always updates lastInteractionAt
DescriptionIn This undermines the new lifecycle behavior (introduced elsewhere in this diff) that intends cron/heartbeat/internal system events not to keep sessions alive:
Impact:
Vulnerable code: const next: SessionEntry = {
...entry,
sessionId,
updatedAt: now,
sessionStartedAt: entry.sessionStartedAt ?? now,
lastInteractionAt: now,
};RecommendationOnly update For example: const now = Date.now();
const hasExplicitOverride = Boolean(thinkOverride || verboseOverride /* || other overrides */);
const shouldTouchInteraction =
hasExplicitOverride &&
opts.bootstrapContextRunKind !== "cron" &&
opts.bootstrapContextRunKind !== "heartbeat" &&
!opts.internalEvents?.length;
const next: SessionEntry = {
...entry,
sessionId,
updatedAt: now,
sessionStartedAt: entry.sessionStartedAt ?? now,
lastInteractionAt: shouldTouchInteraction ? now : entry.lastInteractionAt,
};Alternatively, gate the entire persistence block behind 3. 🔵 Client-controlled session lifecycle flags allow tampering with lastInteractionAt updates
DescriptionIn
Vulnerable code: const touchInteraction =
request.bootstrapContextRunKind !== "cron" &&
request.bootstrapContextRunKind !== "heartbeat" &&
!request.internalEvents?.length;
...
lastInteractionAt: touchInteraction ? now : entry?.lastInteractionAt,If multiple principals can call the same agent/session key (e.g., shared/global sessions), this becomes a session-lifecycle tampering vector that can impact other users by forcing unexpected resets or preventing expected resets. RecommendationDo not trust client input for lifecycle bookkeeping flags.
Example (conceptual): const isInternalCaller = client?.connect?.client?.id === GATEWAY_CLIENT_IDS.SYSTEM;
const runKind = isInternalCaller ? request.bootstrapContextRunKind : "default";
const internalEvents = isInternalCaller ? request.internalEvents : undefined;
const touchInteraction = runKind !== "cron" && runKind !== "heartbeat" && !internalEvents?.length;This prevents external callers from manipulating 4. 🔵 Session freshness can be manipulated via untrusted transcript header timestamp fallback
Description
If an attacker can modify/delete/recreate transcript files (e.g., due to group-writable state dir, misconfigured
Vulnerable code: return parseTimestampMs(header.timestamp);There is no plausibility check (e.g., rejecting timestamps far in the future relative to RecommendationTreat transcript header timestamps as untrusted and apply sanity bounds before using them for lifecycle decisions. Suggested hardening:
Example: function clampHeaderTimestampMs(ts: number | undefined, now: number): number | undefined {
if (ts == null) return undefined;
const maxFutureSkewMs = 5 * 60_000;
if (ts > now + maxFutureSkewMs) return undefined;
return ts;
}
// ...
const parsed = parseTimestampMs(header.timestamp);
return clampHeaderTimestampMs(parsed, Date.now());Analyzed PR: #71845 at commit Last updated on: 2026-04-26T01:31:55Z |
PR SummaryMedium Risk Overview System/background turns no longer keep sessions “fresh”. Heartbeat/cron/exec/internal bookkeeping paths write Legacy compatibility is added for older stores. A new Reviewed by Cursor Bugbot for commit 1f42c3f. Bugbot is set up for automated code reviews on this repo. Configure here. |
Greptile SummaryThis PR adds
Confidence Score: 3/5PR has a genuine behavioural regression in the cron isolated-agent path that reverses the idle-reset fix for sessions managed through that code path. A single P1 defect is present: cron sessions always advance src/cron/isolated-agent/session.ts — Prompt To Fix All With AIThis is a comment left during a code review.
Path: src/cron/isolated-agent/session.ts
Line: 184
Comment:
**Cron always advances `lastInteractionAt`, defeating idle-reset fix**
Every invocation of `resolveCronSession` unconditionally writes `lastInteractionAt: params.nowMs`. The PR's stated goal is to *keep cron from extending reset freshness*, but this line advances the idle expiry timer on every cron tick for sessions managed through the isolated-agent path. The gateway and auto-reply paths correctly gate this with a `touchInteraction` flag or an `isSystemEvent` guard — the cron path has no equivalent.
When a session is configured with an idle-minutes reset, any cron run will keep the session alive indefinitely, which is the exact bug being fixed everywhere else.
```suggestion
lastInteractionAt: isNewSession ? params.nowMs : (baseEntry?.lastInteractionAt ?? params.nowMs),
```
How can I resolve this? If you propose a fix, please make it concise.
---
This is a comment left during a code review.
Path: src/gateway/server-methods/agent.ts
Line: 720-727
Comment:
**Duplicate `resolveSessionLifecycleTimestamps` call may double-read the JSONL file**
`resolveSessionLifecycleTimestamps` is called once to populate `freshness` (~line 658) and again here when building `nextEntryPatch`. For legacy sessions where `entry.sessionStartedAt` is absent, `readSessionHeaderStartedAtMs` performs a synchronous filesystem open/read/close on each call. Capturing the first result in a variable eliminates the redundant I/O:
```ts
const lifecycleTs = resolveSessionLifecycleTimestamps({
entry,
storePath,
agentId: resolveAgentIdFromSessionKey(canonicalKey),
});
// use lifecycleTs for both freshness and nextEntryPatch
```
How can I resolve this? If you propose a fix, please make it concise.Reviews (1): Last reviewed commit: "fix: keep system events from extending s..." | Re-trigger Greptile |
| agentId: params.agentId, | ||
| storePath, | ||
| }).sessionStartedAt), | ||
| lastInteractionAt: params.nowMs, |
There was a problem hiding this comment.
Cron always advances
lastInteractionAt, defeating idle-reset fix
Every invocation of resolveCronSession unconditionally writes lastInteractionAt: params.nowMs. The PR's stated goal is to keep cron from extending reset freshness, but this line advances the idle expiry timer on every cron tick for sessions managed through the isolated-agent path. The gateway and auto-reply paths correctly gate this with a touchInteraction flag or an isSystemEvent guard — the cron path has no equivalent.
When a session is configured with an idle-minutes reset, any cron run will keep the session alive indefinitely, which is the exact bug being fixed everywhere else.
| lastInteractionAt: params.nowMs, | |
| lastInteractionAt: isNewSession ? params.nowMs : (baseEntry?.lastInteractionAt ?? params.nowMs), |
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/cron/isolated-agent/session.ts
Line: 184
Comment:
**Cron always advances `lastInteractionAt`, defeating idle-reset fix**
Every invocation of `resolveCronSession` unconditionally writes `lastInteractionAt: params.nowMs`. The PR's stated goal is to *keep cron from extending reset freshness*, but this line advances the idle expiry timer on every cron tick for sessions managed through the isolated-agent path. The gateway and auto-reply paths correctly gate this with a `touchInteraction` flag or an `isSystemEvent` guard — the cron path has no equivalent.
When a session is configured with an idle-minutes reset, any cron run will keep the session alive indefinitely, which is the exact bug being fixed everywhere else.
```suggestion
lastInteractionAt: isNewSession ? params.nowMs : (baseEntry?.lastInteractionAt ?? params.nowMs),
```
How can I resolve this? If you propose a fix, please make it concise.| sessionStartedAt: isNewSession | ||
| ? now | ||
| : (entry?.sessionStartedAt ?? | ||
| resolveSessionLifecycleTimestamps({ | ||
| entry, | ||
| storePath, | ||
| agentId: resolveAgentIdFromSessionKey(canonicalKey), | ||
| }).sessionStartedAt), |
There was a problem hiding this comment.
Duplicate
resolveSessionLifecycleTimestamps call may double-read the JSONL file
resolveSessionLifecycleTimestamps is called once to populate freshness (~line 658) and again here when building nextEntryPatch. For legacy sessions where entry.sessionStartedAt is absent, readSessionHeaderStartedAtMs performs a synchronous filesystem open/read/close on each call. Capturing the first result in a variable eliminates the redundant I/O:
const lifecycleTs = resolveSessionLifecycleTimestamps({
entry,
storePath,
agentId: resolveAgentIdFromSessionKey(canonicalKey),
});
// use lifecycleTs for both freshness and nextEntryPatchPrompt To Fix With AI
This is a comment left during a code review.
Path: src/gateway/server-methods/agent.ts
Line: 720-727
Comment:
**Duplicate `resolveSessionLifecycleTimestamps` call may double-read the JSONL file**
`resolveSessionLifecycleTimestamps` is called once to populate `freshness` (~line 658) and again here when building `nextEntryPatch`. For legacy sessions where `entry.sessionStartedAt` is absent, `readSessionHeaderStartedAtMs` performs a synchronous filesystem open/read/close on each call. Capturing the first result in a variable eliminates the redundant I/O:
```ts
const lifecycleTs = resolveSessionLifecycleTimestamps({
entry,
storePath,
agentId: resolveAgentIdFromSessionKey(canonicalKey),
});
// use lifecycleTs for both freshness and nextEntryPatch
```
How can I resolve this? If you propose a fix, please make it concise.c138d46 to
26e8435
Compare
| entry, | ||
| agentId: params.agentId, | ||
| storePath, | ||
| }), |
There was a problem hiding this comment.
Repeated synchronous file I/O for legacy timestamp resolution
Low Severity
resolveSessionLifecycleTimestamps is called twice for the same entry in both the cron session and gateway agent paths — once during freshness evaluation and again when constructing the session entry. Each call can trigger synchronous file I/O (fs.openSync/fs.readSync) to read the JSONL header for legacy sessions without sessionStartedAt. The main session path in session.ts correctly avoids this by storing the result in a lifecycleTimestamps variable.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 26e84351d4de0eb9ecbb3de3d0af3526f8843f91. Configure here.
26e8435 to
c8b0dd3
Compare
c8b0dd3 to
1f42c3f
Compare
|
Landed in Codex verification:
The parity-gate failure observed before the final push was the existing qa-lab |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
There are 2 total unresolved issues (including 1 from previous review).
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 1f42c3f. Configure here.
| isNewSession = | ||
| !entry || | ||
| (!canReuseSession && !usableRequestedSessionId) || | ||
| Boolean(usableRequestedSessionId && entry?.sessionId !== usableRequestedSessionId); |
There was a problem hiding this comment.
Gateway agent path missing system-event rollover guard
Medium Severity
The main reply path in session.ts prevents system events from triggering session rollovers via (isSystemEvent && canReuseExistingEntry) in the freshEntry computation. The gateway agent handler computes canReuseSession and isNewSession without an equivalent guard for bootstrapContextRunKind === "cron" or "heartbeat". When a cron or heartbeat request arrives through this path with a stale session, canReuseSession is false, causing an unintended session rollover. Additionally, when this rollover happens, lastInteractionAt is set to the old stale entry?.lastInteractionAt rather than now, so the newly-created session may immediately appear idle-stale.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 1f42c3f. Configure here.


Summary
sessionStartedAtandlastInteractionAtlifecycle timestamps to session rowsupdatedAtsessionStartedAtis missingFixes
Fixes #68315
Fixes #63732
Fixes #63820
Fixes #69083
Supersedes #53014, #47839, and #51026.
Verification
pnpm tsgo --pretty falsepnpm test src/config/sessions/sessions.test.ts src/auto-reply/reply/session.heartbeat-no-reset.test.ts src/agents/command/session-store.test.ts src/cron/isolated-agent/session.test.ts src/gateway/server-methods/agent.test.tspnpm test src/auto-reply/reply/session.heartbeat-no-reset.test.ts src/auto-reply/reply/model-selection.test.tspnpm test src/gateway/server-methods/agent.test.ts src/gateway/server-methods/chat.directive-tags.test.tspnpm format:docs:checkpnpm docs:check-mdxpnpm lint:docsgit diff --checkNote: broad
pnpm check:changedhit unrelated/flaky runner failures; the directly rerun affected shards passed.