VYPR
High severityNVD Advisory· Published Mar 5, 2026· Updated Mar 9, 2026

OpenClaw < 2026.2.12 - Path Traversal via Unsanitized sessionId and sessionFile Parameters

CVE-2026-28482

Description

OpenClaw versions prior to 2026.2.12 construct transcript file paths using unsanitized sessionId parameters and sessionFile paths without enforcing directory containment. Authenticated attackers can exploit path traversal sequences like ../../etc/passwd in sessionId or sessionFile parameters to read or write arbitrary files outside the agent sessions directory.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
openclawnpm
< 2026.2.122026.2.12

Affected products

1

Patches

2
cab0abf52ac9

fix(sessions): resolve transcript paths with explicit agent context (#16288)

https://github.com/openclaw/openclawRobbyFeb 14, 2026via ghsa
10 files changed · +252 12
  • CHANGELOG.md+1 0 modified
    @@ -13,6 +13,7 @@ Docs: https://docs.openclaw.ai
     
     ### Fixes
     
    +- Sessions/Agents: harden transcript path resolution for mismatched agent context by preserving explicit store roots and adding safe absolute-path fallback to the correct agent sessions directory. (#16288) Thanks @robbyczgw-cla.
     - BlueBubbles: include sender identity in group chat envelopes and pass clean message text to the agent prompt, aligning with iMessage/Signal formatting. (#16210) Thanks @zerone0x.
     - WhatsApp: honor per-account `dmPolicy` overrides (account-level settings now take precedence over channel defaults for inbound DMs). (#10082) Thanks @mcaxtr.
     - Security/Node Host: enforce `system.run` rawCommand/argv consistency to prevent allowlist/approval bypass. Thanks @christos-eth.
    
  • src/agents/subagent-announce.ts+2 0 modified
    @@ -230,10 +230,12 @@ async function buildSubagentStatsLine(params: {
       });
     
       const sessionId = entry?.sessionId;
    +  const agentId = resolveAgentIdFromSessionKey(params.sessionKey);
       let transcriptPath: string | undefined;
       if (sessionId && storePath) {
         try {
           transcriptPath = resolveSessionFilePath(sessionId, entry, {
    +        agentId,
             sessionsDir: path.dirname(storePath),
           });
         } catch {
    
  • src/agents/tools/sessions-list-tool.ts+4 1 modified
    @@ -161,7 +161,10 @@ export function createSessionsListTool(opts?: {
                 transcriptPath = resolveSessionFilePath(
                   sessionId,
                   sessionFile ? { sessionFile } : undefined,
    -              { sessionsDir: path.dirname(storePath) },
    +              {
    +                agentId: resolveAgentIdFromSessionKey(key),
    +                sessionsDir: path.dirname(storePath),
    +              },
                 );
               } catch {
                 transcriptPath = undefined;
    
  • src/config/sessions/paths.test.ts+69 3 modified
    @@ -96,30 +96,96 @@ describe("session path safety", () => {
         expect(resolved).toBe(path.resolve(sessionsDir, "abc-123-topic-42.jsonl"));
       });
     
    -  it("rejects absolute sessionFile paths outside the sessions dir", () => {
    +  it("rejects absolute sessionFile paths outside known agent sessions dirs", () => {
         const sessionsDir = "/tmp/openclaw/agents/main/sessions";
     
         expect(() =>
           resolveSessionFilePath(
             "sess-1",
    -        { sessionFile: "/tmp/openclaw/agents/work/sessions/abc-123.jsonl" },
    +        { sessionFile: "/tmp/openclaw/agents/work/not-sessions/abc-123.jsonl" },
             { sessionsDir },
           ),
         ).toThrow(/within sessions directory/);
       });
     
    +  it("uses explicit agentId fallback for absolute sessionFile outside sessionsDir", () => {
    +    const mainSessionsDir = path.dirname(resolveStorePath(undefined, { agentId: "main" }));
    +    const opsSessionsDir = path.dirname(resolveStorePath(undefined, { agentId: "ops" }));
    +    const opsSessionFile = path.join(opsSessionsDir, "abc-123.jsonl");
    +
    +    const resolved = resolveSessionFilePath(
    +      "sess-1",
    +      { sessionFile: opsSessionFile },
    +      { sessionsDir: mainSessionsDir, agentId: "ops" },
    +    );
    +
    +    expect(resolved).toBe(path.resolve(opsSessionFile));
    +  });
    +
    +  it("uses absolute path fallback when sessionFile includes a different agent dir", () => {
    +    const mainSessionsDir = path.dirname(resolveStorePath(undefined, { agentId: "main" }));
    +    const opsSessionsDir = path.dirname(resolveStorePath(undefined, { agentId: "ops" }));
    +    const opsSessionFile = path.join(opsSessionsDir, "abc-123.jsonl");
    +
    +    const resolved = resolveSessionFilePath(
    +      "sess-1",
    +      { sessionFile: opsSessionFile },
    +      { sessionsDir: mainSessionsDir },
    +    );
    +
    +    expect(resolved).toBe(path.resolve(opsSessionFile));
    +  });
    +
    +  it("uses sibling fallback for custom per-agent store roots", () => {
    +    const mainSessionsDir = "/srv/custom/agents/main/sessions";
    +    const opsSessionFile = "/srv/custom/agents/ops/sessions/abc-123.jsonl";
    +
    +    const resolved = resolveSessionFilePath(
    +      "sess-1",
    +      { sessionFile: opsSessionFile },
    +      { sessionsDir: mainSessionsDir, agentId: "ops" },
    +    );
    +
    +    expect(resolved).toBe(path.resolve(opsSessionFile));
    +  });
    +
    +  it("uses extracted agent fallback for custom per-agent store roots", () => {
    +    const mainSessionsDir = "/srv/custom/agents/main/sessions";
    +    const opsSessionFile = "/srv/custom/agents/ops/sessions/abc-123.jsonl";
    +
    +    const resolved = resolveSessionFilePath(
    +      "sess-1",
    +      { sessionFile: opsSessionFile },
    +      { sessionsDir: mainSessionsDir },
    +    );
    +
    +    expect(resolved).toBe(path.resolve(opsSessionFile));
    +  });
    +
       it("uses agent sessions dir fallback for transcript path", () => {
         const resolved = resolveSessionTranscriptPath("sess-1", "main");
         expect(resolved.endsWith(path.join("agents", "main", "sessions", "sess-1.jsonl"))).toBe(true);
       });
     
    -  it("prefers storePath when resolving session file options", () => {
    +  it("keeps storePath and agentId when resolving session file options", () => {
         const opts = resolveSessionFilePathOptions({
           storePath: "/tmp/custom/agent-store/sessions.json",
           agentId: "ops",
         });
         expect(opts).toEqual({
           sessionsDir: path.resolve("/tmp/custom/agent-store"),
    +      agentId: "ops",
    +    });
    +  });
    +
    +  it("keeps custom per-agent store roots when agentId is provided", () => {
    +    const opts = resolveSessionFilePathOptions({
    +      storePath: "/srv/custom/agents/ops/sessions/sessions.json",
    +      agentId: "ops",
    +    });
    +    expect(opts).toEqual({
    +      sessionsDir: path.resolve("/srv/custom/agents/ops/sessions"),
    +      agentId: "ops",
         });
       });
     
    
  • src/config/sessions/paths.ts+77 4 modified
    @@ -42,11 +42,12 @@ export function resolveSessionFilePathOptions(params: {
       agentId?: string;
       storePath?: string;
     }): SessionFilePathOptions | undefined {
    +  const agentId = params.agentId?.trim();
       const storePath = params.storePath?.trim();
       if (storePath) {
    -    return { sessionsDir: path.dirname(path.resolve(storePath)) };
    +    const sessionsDir = path.dirname(path.resolve(storePath));
    +    return agentId ? { sessionsDir, agentId } : { sessionsDir };
       }
    -  const agentId = params.agentId?.trim();
       if (agentId) {
         return { agentId };
       }
    @@ -71,7 +72,51 @@ function resolveSessionsDir(opts?: SessionFilePathOptions): string {
       return resolveAgentSessionsDir(opts?.agentId);
     }
     
    -function resolvePathWithinSessionsDir(sessionsDir: string, candidate: string): string {
    +function resolvePathFromAgentSessionsDir(
    +  agentSessionsDir: string,
    +  candidateAbsPath: string,
    +): string | undefined {
    +  const agentBase = path.resolve(agentSessionsDir);
    +  const relative = path.relative(agentBase, candidateAbsPath);
    +  if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
    +    return undefined;
    +  }
    +  return path.resolve(agentBase, relative);
    +}
    +
    +function resolveSiblingAgentSessionsDir(
    +  baseSessionsDir: string,
    +  agentId: string,
    +): string | undefined {
    +  const resolvedBase = path.resolve(baseSessionsDir);
    +  if (path.basename(resolvedBase) !== "sessions") {
    +    return undefined;
    +  }
    +  const baseAgentDir = path.dirname(resolvedBase);
    +  const baseAgentsDir = path.dirname(baseAgentDir);
    +  if (path.basename(baseAgentsDir) !== "agents") {
    +    return undefined;
    +  }
    +  const rootDir = path.dirname(baseAgentsDir);
    +  return path.join(rootDir, "agents", normalizeAgentId(agentId), "sessions");
    +}
    +
    +function extractAgentIdFromAbsoluteSessionPath(candidateAbsPath: string): string | undefined {
    +  const normalized = path.normalize(path.resolve(candidateAbsPath));
    +  const parts = normalized.split(path.sep).filter(Boolean);
    +  const sessionsIndex = parts.lastIndexOf("sessions");
    +  if (sessionsIndex < 2 || parts[sessionsIndex - 2] !== "agents") {
    +    return undefined;
    +  }
    +  const agentId = parts[sessionsIndex - 1];
    +  return agentId || undefined;
    +}
    +
    +function resolvePathWithinSessionsDir(
    +  sessionsDir: string,
    +  candidate: string,
    +  opts?: { agentId?: string },
    +): string {
       const trimmed = candidate.trim();
       if (!trimmed) {
         throw new Error("Session file path must not be empty");
    @@ -81,6 +126,34 @@ function resolvePathWithinSessionsDir(sessionsDir: string, candidate: string): s
       // Older versions stored absolute sessionFile paths in sessions.json;
       // convert them to relative so the containment check passes.
       const normalized = path.isAbsolute(trimmed) ? path.relative(resolvedBase, trimmed) : trimmed;
    +  if (normalized.startsWith("..") && path.isAbsolute(trimmed)) {
    +    const tryAgentFallback = (agentId: string): string | undefined => {
    +      const normalizedAgentId = normalizeAgentId(agentId);
    +      const siblingSessionsDir = resolveSiblingAgentSessionsDir(resolvedBase, normalizedAgentId);
    +      if (siblingSessionsDir) {
    +        const siblingResolved = resolvePathFromAgentSessionsDir(siblingSessionsDir, trimmed);
    +        if (siblingResolved) {
    +          return siblingResolved;
    +        }
    +      }
    +      return resolvePathFromAgentSessionsDir(resolveAgentSessionsDir(normalizedAgentId), trimmed);
    +    };
    +
    +    const explicitAgentId = opts?.agentId?.trim();
    +    if (explicitAgentId) {
    +      const resolvedFromAgent = tryAgentFallback(explicitAgentId);
    +      if (resolvedFromAgent) {
    +        return resolvedFromAgent;
    +      }
    +    }
    +    const extractedAgentId = extractAgentIdFromAbsoluteSessionPath(trimmed);
    +    if (extractedAgentId) {
    +      const resolvedFromPath = tryAgentFallback(extractedAgentId);
    +      if (resolvedFromPath) {
    +        return resolvedFromPath;
    +      }
    +    }
    +  }
       if (!normalized || normalized.startsWith("..") || path.isAbsolute(normalized)) {
         throw new Error("Session file path must be within sessions directory");
       }
    @@ -122,7 +195,7 @@ export function resolveSessionFilePath(
       const sessionsDir = resolveSessionsDir(opts);
       const candidate = entry?.sessionFile?.trim();
       if (candidate) {
    -    return resolvePathWithinSessionsDir(sessionsDir, candidate);
    +    return resolvePathWithinSessionsDir(sessionsDir, candidate, { agentId: opts?.agentId });
       }
       return resolveSessionTranscriptPathInDir(sessionId, sessionsDir);
     }
    
  • src/config/sessions/transcript.ts+1 0 modified
    @@ -106,6 +106,7 @@ export async function appendAssistantMessageToSessionTranscript(params: {
       let sessionFile: string;
       try {
         sessionFile = resolveSessionFilePath(entry.sessionId, entry, {
    +      agentId: params.agentId,
           sessionsDir: path.dirname(storePath),
         });
       } catch (err) {
    
  • src/gateway/server-methods/chat.ts+8 3 modified
    @@ -53,8 +53,9 @@ function resolveTranscriptPath(params: {
       sessionId: string;
       storePath: string | undefined;
       sessionFile?: string;
    +  agentId?: string;
     }): string | null {
    -  const { sessionId, storePath, sessionFile } = params;
    +  const { sessionId, storePath, sessionFile, agentId } = params;
       if (!storePath && !sessionFile) {
         return null;
       }
    @@ -63,7 +64,7 @@ function resolveTranscriptPath(params: {
         return resolveSessionFilePath(
           sessionId,
           sessionFile ? { sessionFile } : undefined,
    -      sessionsDir ? { sessionsDir } : undefined,
    +      sessionsDir || agentId ? { sessionsDir, agentId } : undefined,
         );
       } catch {
         return null;
    @@ -99,12 +100,14 @@ function appendAssistantTranscriptMessage(params: {
       sessionId: string;
       storePath: string | undefined;
       sessionFile?: string;
    +  agentId?: string;
       createIfMissing?: boolean;
     }): TranscriptAppendResult {
       const transcriptPath = resolveTranscriptPath({
         sessionId: params.sessionId,
         storePath: params.storePath,
         sessionFile: params.sessionFile,
    +    agentId: params.agentId,
       });
       if (!transcriptPath) {
         return { ok: false, error: "transcript path not resolved" };
    @@ -572,6 +575,7 @@ export const chatHandlers: GatewayRequestHandlers = {
                     sessionId,
                     storePath: latestStorePath,
                     sessionFile: latestEntry?.sessionFile,
    +                agentId,
                     createIfMissing: true,
                   });
                   if (appended.ok) {
    @@ -666,7 +670,7 @@ export const chatHandlers: GatewayRequestHandlers = {
     
         // Load session to find transcript file
         const rawSessionKey = p.sessionKey;
    -    const { storePath, entry } = loadSessionEntry(rawSessionKey);
    +    const { cfg, storePath, entry } = loadSessionEntry(rawSessionKey);
         const sessionId = entry?.sessionId;
         if (!sessionId || !storePath) {
           respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "session not found"));
    @@ -679,6 +683,7 @@ export const chatHandlers: GatewayRequestHandlers = {
           sessionId,
           storePath,
           sessionFile: entry?.sessionFile,
    +      agentId: resolveSessionAgentId({ sessionKey: rawSessionKey, config: cfg }),
           createIfMissing: false,
         });
         if (!appended.ok || !appended.messageId || !appended.message) {
    
  • src/gateway/session-utils.fs.test.ts+68 0 modified
    @@ -475,6 +475,58 @@ describe("readSessionMessages", () => {
         expect(marker.__openclaw?.id).toBe("comp-1");
         expect(typeof marker.timestamp).toBe("number");
       });
    +
    +  test("reads cross-agent absolute sessionFile when storePath points to another agent dir", () => {
    +    const sessionId = "cross-agent-default-root";
    +    const sessionFile = path.join(tmpDir, "agents", "ops", "sessions", `${sessionId}.jsonl`);
    +    fs.mkdirSync(path.dirname(sessionFile), { recursive: true });
    +    fs.writeFileSync(
    +      sessionFile,
    +      [
    +        JSON.stringify({ type: "session", version: 1, id: sessionId }),
    +        JSON.stringify({ message: { role: "user", content: "from-ops" } }),
    +      ].join("\n"),
    +      "utf-8",
    +    );
    +
    +    const wrongStorePath = path.join(tmpDir, "agents", "main", "sessions", "sessions.json");
    +    const out = readSessionMessages(sessionId, wrongStorePath, sessionFile);
    +
    +    expect(out).toEqual([{ role: "user", content: "from-ops" }]);
    +  });
    +
    +  test("reads cross-agent absolute sessionFile for custom per-agent store roots", () => {
    +    const sessionId = "cross-agent-custom-root";
    +    const sessionFile = path.join(
    +      tmpDir,
    +      "custom",
    +      "agents",
    +      "ops",
    +      "sessions",
    +      `${sessionId}.jsonl`,
    +    );
    +    fs.mkdirSync(path.dirname(sessionFile), { recursive: true });
    +    fs.writeFileSync(
    +      sessionFile,
    +      [
    +        JSON.stringify({ type: "session", version: 1, id: sessionId }),
    +        JSON.stringify({ message: { role: "assistant", content: "from-custom-ops" } }),
    +      ].join("\n"),
    +      "utf-8",
    +    );
    +
    +    const wrongStorePath = path.join(
    +      tmpDir,
    +      "custom",
    +      "agents",
    +      "main",
    +      "sessions",
    +      "sessions.json",
    +    );
    +    const out = readSessionMessages(sessionId, wrongStorePath, sessionFile);
    +
    +    expect(out).toEqual([{ role: "assistant", content: "from-custom-ops" }]);
    +  });
     });
     
     describe("readSessionPreviewItemsFromTranscript", () => {
    @@ -594,6 +646,22 @@ describe("resolveSessionTranscriptCandidates", () => {
     });
     
     describe("resolveSessionTranscriptCandidates safety", () => {
    +  test("keeps cross-agent absolute sessionFile when storePath agent context differs", () => {
    +    const storePath = "/tmp/openclaw/agents/main/sessions/sessions.json";
    +    const sessionFile = "/tmp/openclaw/agents/ops/sessions/sess-safe.jsonl";
    +    const candidates = resolveSessionTranscriptCandidates("sess-safe", storePath, sessionFile);
    +
    +    expect(candidates.map((value) => path.resolve(value))).toContain(path.resolve(sessionFile));
    +  });
    +
    +  test("keeps cross-agent absolute sessionFile for custom per-agent store roots", () => {
    +    const storePath = "/srv/custom/agents/main/sessions/sessions.json";
    +    const sessionFile = "/srv/custom/agents/ops/sessions/sess-safe.jsonl";
    +    const candidates = resolveSessionTranscriptCandidates("sess-safe", storePath, sessionFile);
    +
    +    expect(candidates.map((value) => path.resolve(value))).toContain(path.resolve(sessionFile));
    +  });
    +
       test("drops unsafe session IDs instead of producing traversal paths", () => {
         const candidates = resolveSessionTranscriptCandidates(
           "../etc/passwd",
    
  • src/gateway/session-utils.fs.ts+3 1 modified
    @@ -131,7 +131,9 @@ export function resolveSessionTranscriptCandidates(
       if (storePath) {
         const sessionsDir = path.dirname(storePath);
         if (sessionFile) {
    -      pushCandidate(() => resolveSessionFilePath(sessionId, { sessionFile }, { sessionsDir }));
    +      pushCandidate(() =>
    +        resolveSessionFilePath(sessionId, { sessionFile }, { sessionsDir, agentId }),
    +      );
         }
         pushCandidate(() => resolveSessionTranscriptPathInDir(sessionId, sessionsDir));
       } else if (sessionFile) {
    
  • src/gateway/session-utils.test.ts+19 0 modified
    @@ -70,6 +70,25 @@ describe("gateway session utils", () => {
         );
       });
     
    +  test("resolveSessionStoreKey falls back to first list entry when no agent is marked default", () => {
    +    const cfg = {
    +      session: { mainKey: "main" },
    +      agents: { list: [{ id: "ops" }, { id: "review" }] },
    +    } as OpenClawConfig;
    +    expect(resolveSessionStoreKey({ cfg, sessionKey: "main" })).toBe("agent:ops:main");
    +    expect(resolveSessionStoreKey({ cfg, sessionKey: "discord:group:123" })).toBe(
    +      "agent:ops:discord:group:123",
    +    );
    +  });
    +
    +  test("resolveSessionStoreKey falls back to main when agents.list is missing", () => {
    +    const cfg = {
    +      session: { mainKey: "work" },
    +    } as OpenClawConfig;
    +    expect(resolveSessionStoreKey({ cfg, sessionKey: "main" })).toBe("agent:main:work");
    +    expect(resolveSessionStoreKey({ cfg, sessionKey: "thread-1" })).toBe("agent:main:thread-1");
    +  });
    +
       test("resolveSessionStoreKey normalizes session key casing", () => {
         const cfg = {
           session: { mainKey: "main" },
    
4199f9889f0c

fix: harden session transcript path resolution

https://github.com/openclaw/openclawPeter SteinbergerFeb 13, 2026via ghsa
13 files changed · +320 64
  • CHANGELOG.md+1 0 modified
    @@ -17,6 +17,7 @@ Docs: https://docs.openclaw.ai
     - Security/Sandbox: confine mirrored skill sync destinations to the sandbox `skills/` root and stop using frontmatter-controlled skill names as filesystem destination paths. Thanks @1seal.
     - Security/Web tools: treat browser/web content as untrusted by default (wrapped outputs for browser snapshot/tabs/console and structured external-content metadata for web tools), and strip `toolResult.details` from model-facing transcript/compaction inputs to reduce prompt-injection replay risk.
     - Security/Hooks: harden webhook and device token verification with shared constant-time secret comparison, and add per-client auth-failure throttling for hook endpoints (`429` + `Retry-After`). Thanks @akhmittra.
    +- Sessions/Gateway: harden transcript path resolution and reject unsafe session IDs/file paths so session operations stay within agent sessions directories. Thanks @akhmittra.
     - Gateway: raise WS payload/buffer limits so 5,000,000-byte image attachments work reliably. (#14486) Thanks @0xRaini.
     - Logging/CLI: use local timezone timestamps for console prefixing, and include `±HH:MM` offsets when using `openclaw logs --local-time` to avoid ambiguity. (#14771) Thanks @0xRaini.
     - Gateway: drain active turns before restart to prevent message loss. (#13931) Thanks @0xRaini.
    
  • src/agents/subagent-announce.ts+11 2 modified
    @@ -6,6 +6,7 @@ import {
       loadSessionStore,
       resolveAgentIdFromSessionKey,
       resolveMainSessionKey,
    +  resolveSessionFilePath,
       resolveStorePath,
     } from "../config/sessions.js";
     import { callGateway } from "../gateway/call.js";
    @@ -229,8 +230,16 @@ async function buildSubagentStatsLine(params: {
       });
     
       const sessionId = entry?.sessionId;
    -  const transcriptPath =
    -    sessionId && storePath ? path.join(path.dirname(storePath), `${sessionId}.jsonl`) : undefined;
    +  let transcriptPath: string | undefined;
    +  if (sessionId && storePath) {
    +    try {
    +      transcriptPath = resolveSessionFilePath(sessionId, entry, {
    +        sessionsDir: path.dirname(storePath),
    +      });
    +    } catch {
    +      transcriptPath = undefined;
    +    }
    +  }
     
       const input = entry?.inputTokens;
       const output = entry?.outputTokens;
    
  • src/agents/tools/sessions-list-tool.ts+15 4 modified
    @@ -2,6 +2,7 @@ import { Type } from "@sinclair/typebox";
     import path from "node:path";
     import type { AnyAgentTool } from "./common.js";
     import { loadConfig } from "../../config/config.js";
    +import { resolveSessionFilePath } from "../../config/sessions.js";
     import { callGateway } from "../../gateway/call.js";
     import { isSubagentSessionKey, resolveAgentIdFromSessionKey } from "../../routing/session-key.js";
     import { jsonResult, readStringArrayParam } from "./common.js";
    @@ -152,10 +153,20 @@ export function createSessionsListTool(opts?: {
             });
     
             const sessionId = typeof entry.sessionId === "string" ? entry.sessionId : undefined;
    -        const transcriptPath =
    -          sessionId && storePath
    -            ? path.join(path.dirname(storePath), `${sessionId}.jsonl`)
    -            : undefined;
    +        const sessionFileRaw = (entry as { sessionFile?: unknown }).sessionFile;
    +        const sessionFile = typeof sessionFileRaw === "string" ? sessionFileRaw : undefined;
    +        let transcriptPath: string | undefined;
    +        if (sessionId && storePath) {
    +          try {
    +            transcriptPath = resolveSessionFilePath(
    +              sessionId,
    +              sessionFile ? { sessionFile } : undefined,
    +              { sessionsDir: path.dirname(storePath) },
    +            );
    +          } catch {
    +            transcriptPath = undefined;
    +          }
    +        }
     
             const row: SessionListRow = {
               key: displayKey,
    
  • src/auto-reply/reply/session.ts+3 0 modified
    @@ -55,10 +55,12 @@ export type SessionInitResult = {
     
     function forkSessionFromParent(params: {
       parentEntry: SessionEntry;
    +  sessionsDir: string;
     }): { sessionId: string; sessionFile: string } | null {
       const parentSessionFile = resolveSessionFilePath(
         params.parentEntry.sessionId,
         params.parentEntry,
    +    { sessionsDir: params.sessionsDir },
       );
       if (!parentSessionFile || !fs.existsSync(parentSessionFile)) {
         return null;
    @@ -320,6 +322,7 @@ export async function initSessionState(params: {
         );
         const forked = forkSessionFromParent({
           parentEntry: sessionStore[parentSessionKey],
    +      sessionsDir: path.dirname(storePath),
         });
         if (forked) {
           sessionId = forked.sessionId;
    
  • src/config/sessions/paths.test.ts+57 1 modified
    @@ -1,6 +1,12 @@
     import path from "node:path";
     import { afterEach, describe, expect, it, vi } from "vitest";
    -import { resolveStorePath } from "./paths.js";
    +import {
    +  resolveSessionFilePath,
    +  resolveSessionTranscriptPath,
    +  resolveSessionTranscriptPathInDir,
    +  resolveStorePath,
    +  validateSessionId,
    +} from "./paths.js";
     
     describe("resolveStorePath", () => {
       afterEach(() => {
    @@ -20,3 +26,53 @@ describe("resolveStorePath", () => {
         );
       });
     });
    +
    +describe("session path safety", () => {
    +  it("validates safe session IDs", () => {
    +    expect(validateSessionId("sess-1")).toBe("sess-1");
    +    expect(validateSessionId("ABC_123.hello")).toBe("ABC_123.hello");
    +  });
    +
    +  it("rejects unsafe session IDs", () => {
    +    expect(() => validateSessionId("../etc/passwd")).toThrow(/Invalid session ID/);
    +    expect(() => validateSessionId("a/b")).toThrow(/Invalid session ID/);
    +    expect(() => validateSessionId("a\\b")).toThrow(/Invalid session ID/);
    +    expect(() => validateSessionId("/abs")).toThrow(/Invalid session ID/);
    +  });
    +
    +  it("resolves transcript path inside an explicit sessions dir", () => {
    +    const sessionsDir = "/tmp/openclaw/agents/main/sessions";
    +    const resolved = resolveSessionTranscriptPathInDir("sess-1", sessionsDir, "topic/a+b");
    +
    +    expect(resolved).toBe(path.resolve(sessionsDir, "sess-1-topic-topic%2Fa%2Bb.jsonl"));
    +  });
    +
    +  it("rejects unsafe sessionFile candidates that escape the sessions dir", () => {
    +    const sessionsDir = "/tmp/openclaw/agents/main/sessions";
    +
    +    expect(() =>
    +      resolveSessionFilePath("sess-1", { sessionFile: "../../etc/passwd" }, { sessionsDir }),
    +    ).toThrow(/within sessions directory/);
    +
    +    expect(() =>
    +      resolveSessionFilePath("sess-1", { sessionFile: "/etc/passwd" }, { sessionsDir }),
    +    ).toThrow(/within sessions directory/);
    +  });
    +
    +  it("accepts sessionFile candidates within the sessions dir", () => {
    +    const sessionsDir = "/tmp/openclaw/agents/main/sessions";
    +
    +    const resolved = resolveSessionFilePath(
    +      "sess-1",
    +      { sessionFile: "subdir/threaded-session.jsonl" },
    +      { sessionsDir },
    +    );
    +
    +    expect(resolved).toBe(path.resolve(sessionsDir, "subdir/threaded-session.jsonl"));
    +  });
    +
    +  it("uses agent sessions dir fallback for transcript path", () => {
    +    const resolved = resolveSessionTranscriptPath("sess-1", "main");
    +    expect(resolved.endsWith(path.join("agents", "main", "sessions", "sess-1.jsonl"))).toBe(true);
    +  });
    +});
    
  • src/config/sessions/paths.ts+54 8 modified
    @@ -1,6 +1,5 @@
     import os from "node:os";
     import path from "node:path";
    -import type { SessionEntry } from "./types.js";
     import { expandHomePrefix, resolveRequiredHomeDir } from "../../infra/home-dir.js";
     import { DEFAULT_AGENT_ID, normalizeAgentId } from "../../routing/session-key.js";
     import { resolveStateDir } from "../paths.js";
    @@ -34,29 +33,76 @@ export function resolveDefaultSessionStorePath(agentId?: string): string {
       return path.join(resolveAgentSessionsDir(agentId), "sessions.json");
     }
     
    -export function resolveSessionTranscriptPath(
    +export const SAFE_SESSION_ID_RE = /^[a-z0-9][a-z0-9._-]{0,127}$/i;
    +
    +export function validateSessionId(sessionId: string): string {
    +  const trimmed = sessionId.trim();
    +  if (!SAFE_SESSION_ID_RE.test(trimmed)) {
    +    throw new Error(`Invalid session ID: ${sessionId}`);
    +  }
    +  return trimmed;
    +}
    +
    +function resolveSessionsDir(opts?: { agentId?: string; sessionsDir?: string }): string {
    +  const sessionsDir = opts?.sessionsDir?.trim();
    +  if (sessionsDir) {
    +    return path.resolve(sessionsDir);
    +  }
    +  return resolveAgentSessionsDir(opts?.agentId);
    +}
    +
    +function resolvePathWithinSessionsDir(sessionsDir: string, candidate: string): string {
    +  const trimmed = candidate.trim();
    +  if (!trimmed) {
    +    throw new Error("Session file path must not be empty");
    +  }
    +  const resolvedBase = path.resolve(sessionsDir);
    +  const resolvedCandidate = path.resolve(resolvedBase, trimmed);
    +  const relative = path.relative(resolvedBase, resolvedCandidate);
    +  if (relative.startsWith("..") || path.isAbsolute(relative)) {
    +    throw new Error("Session file path must be within sessions directory");
    +  }
    +  return resolvedCandidate;
    +}
    +
    +export function resolveSessionTranscriptPathInDir(
       sessionId: string,
    -  agentId?: string,
    +  sessionsDir: string,
       topicId?: string | number,
     ): string {
    +  const safeSessionId = validateSessionId(sessionId);
       const safeTopicId =
         typeof topicId === "string"
           ? encodeURIComponent(topicId)
           : typeof topicId === "number"
             ? String(topicId)
             : undefined;
       const fileName =
    -    safeTopicId !== undefined ? `${sessionId}-topic-${safeTopicId}.jsonl` : `${sessionId}.jsonl`;
    -  return path.join(resolveAgentSessionsDir(agentId), fileName);
    +    safeTopicId !== undefined
    +      ? `${safeSessionId}-topic-${safeTopicId}.jsonl`
    +      : `${safeSessionId}.jsonl`;
    +  return resolvePathWithinSessionsDir(sessionsDir, fileName);
    +}
    +
    +export function resolveSessionTranscriptPath(
    +  sessionId: string,
    +  agentId?: string,
    +  topicId?: string | number,
    +): string {
    +  return resolveSessionTranscriptPathInDir(sessionId, resolveAgentSessionsDir(agentId), topicId);
     }
     
     export function resolveSessionFilePath(
       sessionId: string,
    -  entry?: SessionEntry,
    -  opts?: { agentId?: string },
    +  entry?: { sessionFile?: string },
    +  opts?: { agentId?: string; sessionsDir?: string },
     ): string {
    +  const sessionsDir = resolveSessionsDir(opts);
       const candidate = entry?.sessionFile?.trim();
    -  return candidate ? candidate : resolveSessionTranscriptPath(sessionId, opts?.agentId);
    +  if (candidate) {
    +    return resolvePathWithinSessionsDir(sessionsDir, candidate);
    +  }
    +  return resolveSessionTranscriptPathInDir(sessionId, sessionsDir);
     }
     
     export function resolveStorePath(store?: string, opts?: { agentId?: string }) {
    
  • src/config/sessions/transcript.ts+12 3 modified
    @@ -3,7 +3,7 @@ import fs from "node:fs";
     import path from "node:path";
     import type { SessionEntry } from "./types.js";
     import { emitSessionTranscriptUpdate } from "../../sessions/transcript-events.js";
    -import { resolveDefaultSessionStorePath, resolveSessionTranscriptPath } from "./paths.js";
    +import { resolveDefaultSessionStorePath, resolveSessionFilePath } from "./paths.js";
     import { loadSessionStore, updateSessionStore } from "./store.js";
     
     function stripQuery(value: string): string {
    @@ -103,8 +103,17 @@ export async function appendAssistantMessageToSessionTranscript(params: {
         return { ok: false, reason: `unknown sessionKey: ${sessionKey}` };
       }
     
    -  const sessionFile =
    -    entry.sessionFile?.trim() || resolveSessionTranscriptPath(entry.sessionId, params.agentId);
    +  let sessionFile: string;
    +  try {
    +    sessionFile = resolveSessionFilePath(entry.sessionId, entry, {
    +      sessionsDir: path.dirname(storePath),
    +    });
    +  } catch (err) {
    +    return {
    +      ok: false,
    +      reason: err instanceof Error ? err.message : String(err),
    +    };
    +  }
     
       await ensureSessionHeader({ sessionFile, sessionId: entry.sessionId });
     
    
  • src/gateway/server-methods/chat.inject.parentid.test.ts+1 1 modified
    @@ -30,7 +30,7 @@ describe("gateway chat.inject transcript writes", () => {
           return {
             ...original,
             loadSessionEntry: () => ({
    -          storePath: "/tmp/store.json",
    +          storePath: path.join(dir, "sessions.json"),
               entry: {
                 sessionId: "sess-1",
                 sessionFile: transcriptPath,
    
  • src/gateway/server-methods/chat.ts+11 4 modified
    @@ -9,6 +9,7 @@ import { resolveAgentTimeoutMs } from "../../agents/timeout.js";
     import { dispatchInboundMessage } from "../../auto-reply/dispatch.js";
     import { createReplyDispatcher } from "../../auto-reply/reply/reply-dispatcher.js";
     import { createReplyPrefixOptions } from "../../channels/reply-prefix.js";
    +import { resolveSessionFilePath } from "../../config/sessions.js";
     import { resolveSendPolicy } from "../../sessions/send-policy.js";
     import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.js";
     import {
    @@ -54,13 +55,19 @@ function resolveTranscriptPath(params: {
       sessionFile?: string;
     }): string | null {
       const { sessionId, storePath, sessionFile } = params;
    -  if (sessionFile) {
    -    return sessionFile;
    +  if (!storePath && !sessionFile) {
    +    return null;
       }
    -  if (!storePath) {
    +  try {
    +    const sessionsDir = storePath ? path.dirname(storePath) : undefined;
    +    return resolveSessionFilePath(
    +      sessionId,
    +      sessionFile ? { sessionFile } : undefined,
    +      sessionsDir ? { sessionsDir } : undefined,
    +    );
    +  } catch {
         return null;
       }
    -  return path.join(path.dirname(storePath), `${sessionId}.jsonl`);
     }
     
     function ensureTranscriptFile(params: { transcriptPath: string; sessionId: string }): {
    
  • src/gateway/server-methods/usage.sessions-usage.test.ts+55 22 modified
    @@ -107,40 +107,73 @@ describe("sessions.usage", () => {
     
       it("resolves store entries by sessionId when queried via discovered agent-prefixed key", async () => {
         const storeKey = "agent:opus:slack:dm:u123";
    -    const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-usage-test-"));
    -    const sessionFile = path.join(tempDir, "s-opus.jsonl");
    -    fs.writeFileSync(sessionFile, "", "utf-8");
    -    const respond = vi.fn();
    +    const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-usage-test-"));
    +    const previousStateDir = process.env.OPENCLAW_STATE_DIR;
    +    process.env.OPENCLAW_STATE_DIR = stateDir;
    +
    +    try {
    +      const agentSessionsDir = path.join(stateDir, "agents", "opus", "sessions");
    +      fs.mkdirSync(agentSessionsDir, { recursive: true });
    +      const sessionFile = path.join(agentSessionsDir, "s-opus.jsonl");
    +      fs.writeFileSync(sessionFile, "", "utf-8");
    +      const respond = vi.fn();
     
    -    // Swap the store mock for this test: the canonical key differs from the discovered key
    -    // but points at the same sessionId.
    -    vi.mocked(loadCombinedSessionStoreForGateway).mockReturnValue({
    -      storePath: "(multiple)",
    -      store: {
    -        [storeKey]: {
    -          sessionId: "s-opus",
    -          sessionFile,
    -          label: "Named session",
    -          updatedAt: 999,
    +      // Swap the store mock for this test: the canonical key differs from the discovered key
    +      // but points at the same sessionId.
    +      vi.mocked(loadCombinedSessionStoreForGateway).mockReturnValue({
    +        storePath: "(multiple)",
    +        store: {
    +          [storeKey]: {
    +            sessionId: "s-opus",
    +            sessionFile: "s-opus.jsonl",
    +            label: "Named session",
    +            updatedAt: 999,
    +          },
             },
    -      },
    -    });
    +      });
    +
    +      // Query via discovered key: agent:<id>:<sessionId>
    +      await usageHandlers["sessions.usage"]({
    +        respond,
    +        params: {
    +          startDate: "2026-02-01",
    +          endDate: "2026-02-02",
    +          key: "agent:opus:s-opus",
    +          limit: 10,
    +        },
    +      } as unknown as Parameters<(typeof usageHandlers)["sessions.usage"]>[0]);
    +
    +      expect(respond).toHaveBeenCalledTimes(1);
    +      expect(respond.mock.calls[0]?.[0]).toBe(true);
    +      const result = respond.mock.calls[0]?.[1] as unknown as { sessions: Array<{ key: string }> };
    +      expect(result.sessions).toHaveLength(1);
    +      expect(result.sessions[0]?.key).toBe(storeKey);
    +    } finally {
    +      if (previousStateDir === undefined) {
    +        delete process.env.OPENCLAW_STATE_DIR;
    +      } else {
    +        process.env.OPENCLAW_STATE_DIR = previousStateDir;
    +      }
    +      fs.rmSync(stateDir, { recursive: true, force: true });
    +    }
    +  });
    +
    +  it("rejects traversal-style keys in specific session usage lookups", async () => {
    +    const respond = vi.fn();
     
    -    // Query via discovered key: agent:<id>:<sessionId>
         await usageHandlers["sessions.usage"]({
           respond,
           params: {
             startDate: "2026-02-01",
             endDate: "2026-02-02",
    -        key: "agent:opus:s-opus",
    +        key: "agent:opus:../../etc/passwd",
             limit: 10,
           },
         } as unknown as Parameters<(typeof usageHandlers)["sessions.usage"]>[0]);
     
         expect(respond).toHaveBeenCalledTimes(1);
    -    expect(respond.mock.calls[0]?.[0]).toBe(true);
    -    const result = respond.mock.calls[0]?.[1] as unknown as { sessions: Array<{ key: string }> };
    -    expect(result.sessions).toHaveLength(1);
    -    expect(result.sessions[0]?.key).toBe(storeKey);
    +    expect(respond.mock.calls[0]?.[0]).toBe(false);
    +    const error = respond.mock.calls[0]?.[2] as { message?: string } | undefined;
    +    expect(error?.message).toContain("Invalid session reference");
       });
     });
    
  • src/gateway/server-methods/usage.ts+43 10 modified
    @@ -1,4 +1,5 @@
     import fs from "node:fs";
    +import path from "node:path";
     import type { SessionEntry, SessionSystemPromptReport } from "../../config/sessions/types.js";
     import type {
       CostUsageSummary,
    @@ -291,7 +292,7 @@ export const usageHandlers: GatewayRequestHandlers = {
         const specificKey = typeof p.key === "string" ? p.key.trim() : null;
     
         // Load session store for named sessions
    -    const { store } = loadCombinedSessionStoreForGateway(config);
    +    const { storePath, store } = loadCombinedSessionStoreForGateway(config);
         const now = Date.now();
     
         // Merge discovered sessions with store entries
    @@ -331,9 +332,21 @@ export const usageHandlers: GatewayRequestHandlers = {
           const sessionId = storeEntry?.sessionId ?? keyRest;
     
           // Resolve the session file path
    -      const sessionFile = resolveSessionFilePath(sessionId, storeEntry, {
    -        agentId: agentIdFromKey,
    -      });
    +      let sessionFile: string;
    +      try {
    +        const pathOpts =
    +          storePath && storePath !== "(multiple)"
    +            ? { sessionsDir: path.dirname(storePath) }
    +            : { agentId: agentIdFromKey };
    +        sessionFile = resolveSessionFilePath(sessionId, storeEntry, pathOpts);
    +      } catch {
    +        respond(
    +          false,
    +          undefined,
    +          errorShape(ErrorCodes.INVALID_REQUEST, `Invalid session reference: ${specificKey}`),
    +        );
    +        return;
    +      }
     
           try {
             const stats = fs.statSync(sessionFile);
    @@ -756,15 +769,25 @@ export const usageHandlers: GatewayRequestHandlers = {
         }
     
         const config = loadConfig();
    -    const { entry } = loadSessionEntry(key);
    +    const { entry, storePath } = loadSessionEntry(key);
     
         // For discovered sessions (not in store), try using key as sessionId directly
         const parsed = parseAgentSessionKey(key);
         const agentId = parsed?.agentId;
         const rawSessionId = parsed?.rest ?? key;
         const sessionId = entry?.sessionId ?? rawSessionId;
    -    const sessionFile =
    -      entry?.sessionFile ?? resolveSessionFilePath(rawSessionId, entry, { agentId });
    +    let sessionFile: string;
    +    try {
    +      const pathOpts = storePath ? { sessionsDir: path.dirname(storePath) } : { agentId };
    +      sessionFile = resolveSessionFilePath(sessionId, entry, pathOpts);
    +    } catch {
    +      respond(
    +        false,
    +        undefined,
    +        errorShape(ErrorCodes.INVALID_REQUEST, `Invalid session key: ${key}`),
    +      );
    +      return;
    +    }
     
         const timeseries = await loadSessionUsageTimeSeries({
           sessionId,
    @@ -798,15 +821,25 @@ export const usageHandlers: GatewayRequestHandlers = {
             : 200;
     
         const config = loadConfig();
    -    const { entry } = loadSessionEntry(key);
    +    const { entry, storePath } = loadSessionEntry(key);
     
         // For discovered sessions (not in store), try using key as sessionId directly
         const parsed = parseAgentSessionKey(key);
         const agentId = parsed?.agentId;
         const rawSessionId = parsed?.rest ?? key;
         const sessionId = entry?.sessionId ?? rawSessionId;
    -    const sessionFile =
    -      entry?.sessionFile ?? resolveSessionFilePath(rawSessionId, entry, { agentId });
    +    let sessionFile: string;
    +    try {
    +      const pathOpts = storePath ? { sessionsDir: path.dirname(storePath) } : { agentId };
    +      sessionFile = resolveSessionFilePath(sessionId, entry, pathOpts);
    +    } catch {
    +      respond(
    +        false,
    +        undefined,
    +        errorShape(ErrorCodes.INVALID_REQUEST, `Invalid session key: ${key}`),
    +      );
    +      return;
    +    }
     
         const { loadSessionLogs } = await import("../../infra/session-cost-usage.js");
         const logs = await loadSessionLogs({
    
  • src/gateway/session-utils.fs.test.ts+23 0 modified
    @@ -507,3 +507,26 @@ describe("resolveSessionTranscriptCandidates", () => {
         );
       });
     });
    +
    +describe("resolveSessionTranscriptCandidates safety", () => {
    +  test("drops unsafe session IDs instead of producing traversal paths", () => {
    +    const candidates = resolveSessionTranscriptCandidates(
    +      "../etc/passwd",
    +      "/tmp/openclaw/agents/main/sessions/sessions.json",
    +    );
    +
    +    expect(candidates).toEqual([]);
    +  });
    +
    +  test("drops unsafe sessionFile candidates and keeps safe fallbacks", () => {
    +    const storePath = "/tmp/openclaw/agents/main/sessions/sessions.json";
    +    const candidates = resolveSessionTranscriptCandidates(
    +      "sess-safe",
    +      storePath,
    +      "../../etc/passwd",
    +    );
    +
    +    expect(candidates.some((value) => value.includes("etc/passwd"))).toBe(false);
    +    expect(candidates).toContain(path.join(path.dirname(storePath), "sess-safe.jsonl"));
    +  });
    +});
    
  • src/gateway/session-utils.fs.ts+34 9 modified
    @@ -2,7 +2,11 @@ import fs from "node:fs";
     import os from "node:os";
     import path from "node:path";
     import type { SessionPreviewItem } from "./session-utils.types.js";
    -import { resolveSessionTranscriptPath } from "../config/sessions.js";
    +import {
    +  resolveSessionFilePath,
    +  resolveSessionTranscriptPath,
    +  resolveSessionTranscriptPathInDir,
    +} from "../config/sessions.js";
     import { resolveRequiredHomeDir } from "../infra/home-dir.js";
     import { extractToolCallNames, hasToolCall } from "../utils/transcript-tools.js";
     import { stripEnvelope } from "./chat-sanitize.js";
    @@ -61,19 +65,40 @@ export function resolveSessionTranscriptCandidates(
       agentId?: string,
     ): string[] {
       const candidates: string[] = [];
    -  if (sessionFile) {
    -    candidates.push(sessionFile);
    -  }
    +  const pushCandidate = (resolve: () => string): void => {
    +    try {
    +      candidates.push(resolve());
    +    } catch {
    +      // Ignore invalid paths/IDs and keep scanning other safe candidates.
    +    }
    +  };
    +
       if (storePath) {
    -    const dir = path.dirname(storePath);
    -    candidates.push(path.join(dir, `${sessionId}.jsonl`));
    +    const sessionsDir = path.dirname(storePath);
    +    if (sessionFile) {
    +      pushCandidate(() => resolveSessionFilePath(sessionId, { sessionFile }, { sessionsDir }));
    +    }
    +    pushCandidate(() => resolveSessionTranscriptPathInDir(sessionId, sessionsDir));
    +  } else if (sessionFile) {
    +    if (agentId) {
    +      pushCandidate(() => resolveSessionFilePath(sessionId, { sessionFile }, { agentId }));
    +    } else {
    +      const trimmed = sessionFile.trim();
    +      if (trimmed) {
    +        candidates.push(path.resolve(trimmed));
    +      }
    +    }
       }
    +
       if (agentId) {
    -    candidates.push(resolveSessionTranscriptPath(sessionId, agentId));
    +    pushCandidate(() => resolveSessionTranscriptPath(sessionId, agentId));
       }
    +
       const home = resolveRequiredHomeDir(process.env, os.homedir);
    -  candidates.push(path.join(home, ".openclaw", "sessions", `${sessionId}.jsonl`));
    -  return candidates;
    +  const legacyDir = path.join(home, ".openclaw", "sessions");
    +  pushCandidate(() => resolveSessionTranscriptPathInDir(sessionId, legacyDir));
    +
    +  return Array.from(new Set(candidates));
     }
     
     export function archiveFileOnDisk(filePath: string, reason: string): string {
    

Vulnerability mechanics

Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

7

News mentions

0

No linked articles in our index yet.