fix: clear phantom Claude CLI resumes#70317
Conversation
🔒 Aisle Security AnalysisWe found 3 potential security issue(s) in this PR:
1. 🟡 TOCTOU symlink race when inspecting JSONL transcripts (lstat then open)
Description
Impact depends on the privilege boundary:
Vulnerable flow:
Vulnerable code: const stat = await fs.lstat(filePath);
if (stat.isSymbolicLink() || !stat.isFile()) {
return false;
}
const fh = await fs.open(filePath, "r");RecommendationEliminate the path-based TOCTOU by opening the file safely and validating the opened file descriptor, or by using OS flags that prevent symlink following. Recommended approach (POSIX): open with import fs from "node:fs/promises";
import { constants as FS } from "node:fs";
const fh = await fs.open(filePath, FS.O_RDONLY | FS.O_NOFOLLOW);
try {
const st = await fh.stat();
if (!st.isFile()) return false;
// proceed to stream from fh
} finally {
await fh.close();
}If
This closes the race because the checks apply to the already-open file, not a name that can be swapped. 2. 🔵 Unbounded enumeration of ~/.claude/projects and parsing of arbitrary-size JSONL lines can cause local DoS
DescriptionThe new
This can be triggered whenever RecommendationAdd explicit resource limits for both directory scanning and JSONL parsing. Suggested mitigations:
const MAX_PROJECT_DIRS = 50;
const projectEntries = (await fs.readdir(projectsDir, { withFileTypes: true }))
.filter((e) => e.isDirectory())
.slice(0, MAX_PROJECT_DIRS);
const MAX_TRANSCRIPT_BYTES = 2 * 1024 * 1024; // 2MB
const st = await fs.stat(filePath);
if (st.size > MAX_TRANSCRIPT_BYTES) return false;
const MAX_LINE_CHARS = 64 * 1024;
for await (const line of rl) {
if (line.length > MAX_LINE_CHARS) return false; // or break
...
}
3. 🔵 Log injection via unsanitized sessionKey/sessionId in warning message
DescriptionA new warning log line interpolates
Vulnerable code: log.warn(
`cli session reset: provider=${sanitizeForLog(params.providerOverride)} reason=transcript-missing sessionKey=${params.sessionKey ?? params.sessionId}`,
);RecommendationApply Minimal fix (sanitization): const sessionKeyForLog = sanitizeForLog(String(params.sessionKey ?? params.sessionId ?? ""));
log.warn(
`cli session reset: provider=${sanitizeForLog(params.providerOverride)} reason=transcript-missing sessionKey=${sessionKeyForLog}`,
);If session IDs/keys are considered sensitive, prefer redaction/truncation: const raw = String(params.sessionKey ?? params.sessionId ?? "");
const sessionKeyForLog = sanitizeForLog(raw).slice(0, 8) + "…";Even better, use structured logging fields (if supported) rather than string interpolation. Analyzed PR: #70317 at commit Last updated on: 2026-04-22T19:37:54Z |
PR SummaryMedium Risk Overview Refactors transcript-content checks into a shared JSONL scanner with basic path/symlink/file-type safeguards, and adds regression tests covering missing transcripts, valid transcripts, and path-like session ids (plus HOME isolation in CLI attempt tests). Reviewed by Cursor Bugbot for commit c216cf9. Bugbot is set up for automated code reviews on this repo. Configure here. |
9230bd8 to
c216cf9
Compare
|
Landed via rebase onto main.
Fixes #70177. |
Greptile SummaryThis PR fixes "phantom" Claude CLI resume attempts by verifying that a stored Confidence Score: 5/5Safe to merge; the logic is correct and well-tested, with no P0/P1 issues found. The only finding is a P2 note about broadening the No files require special attention. Prompt To Fix All With AIThis is a comment left during a code review.
Path: src/agents/command/attempt-execution.helpers.ts
Line: 54-56
Comment:
**`type` guard removed — `sessionFileHasContent` now matches more broadly**
The original check was `rec?.type === "message" && message?.role === "assistant"`. Removing the `type === "message"` guard unifies behaviour with the Claude CLI format (`type: "assistant"`), but it also changes what `sessionFileHasContent` accepts for OpenClaw's own session files. Any JSONL record that happens to carry a `message.role === "assistant"` field will now return `true`, even if `type` is something other than `"message"`.
This is intentional for the new Claude CLI path, but worth confirming that no caller of `sessionFileHasContent` relied on the stricter type filter to distinguish real assistant turns from, e.g., metadata or tool-result records.
How can I resolve this? If you propose a fix, please make it concise.Reviews (1): Last reviewed commit: "fix: clear phantom Claude CLI resumes (#..." | Re-trigger Greptile |
| if ((rec?.message as Record<string, unknown> | undefined)?.role === "assistant") { | ||
| return true; | ||
| } |
There was a problem hiding this comment.
type guard removed — sessionFileHasContent now matches more broadly
The original check was rec?.type === "message" && message?.role === "assistant". Removing the type === "message" guard unifies behaviour with the Claude CLI format (type: "assistant"), but it also changes what sessionFileHasContent accepts for OpenClaw's own session files. Any JSONL record that happens to carry a message.role === "assistant" field will now return true, even if type is something other than "message".
This is intentional for the new Claude CLI path, but worth confirming that no caller of sessionFileHasContent relied on the stricter type filter to distinguish real assistant turns from, e.g., metadata or tool-result records.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agents/command/attempt-execution.helpers.ts
Line: 54-56
Comment:
**`type` guard removed — `sessionFileHasContent` now matches more broadly**
The original check was `rec?.type === "message" && message?.role === "assistant"`. Removing the `type === "message"` guard unifies behaviour with the Claude CLI format (`type: "assistant"`), but it also changes what `sessionFileHasContent` accepts for OpenClaw's own session files. Any JSONL record that happens to carry a `message.role === "assistant"` field will now return `true`, even if `type` is something other than `"message"`.
This is intentional for the new Claude CLI path, but worth confirming that no caller of `sessionFileHasContent` relied on the stricter type filter to distinguish real assistant turns from, e.g., metadata or tool-result records.
How can I resolve this? If you propose a fix, please make it concise.Verify Claude CLI session transcripts before reuse and clear phantom bindings with transcript-missing instead of passing stale --resume ids.\n\nFixes openclaw#70177.
Verify Claude CLI session transcripts before reuse and clear phantom bindings with transcript-missing instead of passing stale --resume ids.\n\nFixes openclaw#70177.
Verify Claude CLI session transcripts before reuse and clear phantom bindings with transcript-missing instead of passing stale --resume ids.\n\nFixes openclaw#70177.
Verify Claude CLI session transcripts before reuse and clear phantom bindings with transcript-missing instead of passing stale --resume ids.\n\nFixes openclaw#70177.
Verify Claude CLI session transcripts before reuse and clear phantom bindings with transcript-missing instead of passing stale --resume ids.\n\nFixes openclaw#70177.
Verify Claude CLI session transcripts before reuse and clear phantom bindings with transcript-missing instead of passing stale --resume ids.\n\nFixes openclaw#70177.
Verify Claude CLI session transcripts before reuse and clear phantom bindings with transcript-missing instead of passing stale --resume ids.\n\nFixes openclaw#70177.
Verify Claude CLI session transcripts before reuse and clear phantom bindings with transcript-missing instead of passing stale --resume ids.\n\nFixes openclaw#70177.
Summary
~/.claude/projects/*/<sessionId>.jsonltranscript with assistant content before reusing them--resumeFixes #70177.
Testing
pnpm test src/agents/command/attempt-execution.test.ts src/agents/command/attempt-execution.cli.test.ts src/agents/command/session-store.test.tspnpm check:changedpnpm checkOPENCLAW_VITEST_MAX_WORKERS=1 pnpm check:changed --stagedFull suite note
pnpm testwas also attempted. The run hit unrelated failures outside this patch:src/gateway/server-startup.test.tstimed out inskips static warmup for configured CLI backendsextensions/openai/provider-runtime.contract.test.tsfailed during OpenAI Codex OAuth refresh withFailed to refresh OpenAI Codex tokenvitest.contracts-channel-surface.config.tsafter that shard had already printed a pass summary, so it was stopped withSIGTERM