fix(session): store session history outside the agent workspace

Session files lived under <workspace>/sessions/ (since #713), which is the
on-disk scope of the agent's filesystem tools. With restrict_to_workspace
enabled, an agent could read_file / list_dir every session transcript —
including other users' or channels' conversations — bypassing the scoped
sessions.py access layer entirely.

Move session storage to ~/.nanobot/sessions/<sha256-of-resolved-workspace>[:16]/,
outside the workspace. Per-workspace isolation (the goal of #713) is preserved
via a hash of the resolved workspace path, so different workspaces keep
independent session stores. A one-shot, idempotent migration moves legacy
in-workspace *.jsonl files into the new location at store init.

Scope note: this protects sessions whenever restrict_to_workspace=true. The
default restrict_to_workspace=false leaves read_file unrestricted in general
(not only sessions) and is a separate concern.

Refs #5278
This commit is contained in:
李明振
2026-08-13 01:41:10 +09:00
committed by Xubin Ren
parent edaef4e4f5
commit b34f1bd0e8
3 changed files with 156 additions and 2 deletions
+39 -2
View File
@@ -2,9 +2,11 @@
import base64
import errno
import hashlib
import json
import os
import re
import shutil
from collections import OrderedDict
from contextlib import suppress
from copy import deepcopy
@@ -521,8 +523,43 @@ class JsonlSessionStore:
"""JSONL implementation of session persistence."""
def __init__(self, workspace: Path):
self.sessions_dir = ensure_dir(workspace / "sessions")
self.legacy_sessions_dir = get_legacy_sessions_dir()
root = get_legacy_sessions_dir()
self.sessions_dir = ensure_dir(root / self._workspace_hash(workspace))
self.legacy_sessions_dir = root
self._write_workspace_marker(self.sessions_dir, workspace)
self._migrate_from_workspace(workspace)
@staticmethod
def _workspace_hash(workspace: Path) -> str:
canonical = str(Path(workspace).expanduser().resolve(strict=False))
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:16]
@staticmethod
def _write_workspace_marker(sessions_dir: Path, workspace: Path) -> None:
marker = sessions_dir / ".workspace"
if marker.exists():
return
try:
marker.write_text(
str(Path(workspace).expanduser().resolve(strict=False)),
encoding="utf-8",
)
except OSError as exc:
logger.debug("Failed to write sessions workspace marker: {}", exc)
def _migrate_from_workspace(self, workspace: Path) -> None:
"""Move legacy in-workspace session files into the out-of-workspace store."""
old_dir = Path(workspace).expanduser() / "sessions"
if not old_dir.is_dir():
return
for src in old_dir.glob("*.jsonl"):
dst = self.sessions_dir / src.name
if dst.exists():
continue
try:
shutil.move(str(src), str(dst))
except OSError as exc:
logger.warning("Failed to migrate session {}: {}", src, exc)
@staticmethod
def safe_key(key: str) -> str: