Skip to content

Commit e844e53

Browse files
feat(sessions): optional AES-256-GCM encryption for saved sessions
Add --session-encrypt with CECLI_SESSION_KEY or --session-key-file, wire encrypt/decrypt through SessionManager save/load/list, and document usage. Plaintext JSON remains the default when encryption is off. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 855931c commit e844e53

9 files changed

Lines changed: 603 additions & 11 deletions

File tree

cecli/args.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,6 +370,24 @@ def get_parser(default_config_files, git_root):
370370
" (default: False)"
371371
),
372372
)
373+
group.add_argument(
374+
"--session-encrypt",
375+
action=argparse.BooleanOptionalAction,
376+
default=False,
377+
help=(
378+
"Encrypt saved sessions on disk (AES-256-GCM). Requires CECLI_SESSION_KEY or"
379+
" --session-key-file (default: False)"
380+
),
381+
)
382+
group.add_argument(
383+
"--session-key-file",
384+
metavar="SESSION_KEY_FILE",
385+
default=None,
386+
help=(
387+
"File containing a urlsafe-base64 32-byte session encryption key"
388+
" (default: use CECLI_SESSION_KEY only)"
389+
),
390+
).complete = shtab.FILE
373391
group.add_argument(
374392
"--mcp-servers",
375393
metavar="MCP_CONFIG_JSON",

cecli/session_crypto.py

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
"""Optional AES-256-GCM encryption for on-disk cecli session files."""
2+
3+
from __future__ import annotations
4+
5+
import base64
6+
import json
7+
import os
8+
from pathlib import Path
9+
from typing import Any
10+
11+
MAGIC = b"CECLI_ENCRYPTED_SESSION_v1\n"
12+
KEY_ENV = "CECLI_SESSION_KEY"
13+
KEY_BYTES = 32
14+
15+
16+
class SessionCryptoError(Exception):
17+
"""Session encrypt/decrypt failed."""
18+
19+
20+
def is_encrypted_payload(data: bytes) -> bool:
21+
return data.startswith(MAGIC)
22+
23+
24+
def resolve_key(*, key_file: str | Path | None = None) -> bytes | None:
25+
"""Load a 32-byte key from CECLI_SESSION_KEY (urlsafe base64) or a key file."""
26+
raw = os.environ.get(KEY_ENV, "").strip()
27+
if raw:
28+
key = _decode_key_b64(raw)
29+
if key is not None:
30+
return key
31+
if key_file:
32+
path = Path(key_file).expanduser()
33+
if path.is_file():
34+
text = path.read_text(encoding="utf-8").strip()
35+
key = _decode_key_b64(text)
36+
if key is not None:
37+
return key
38+
return None
39+
40+
41+
def _decode_key_b64(text: str) -> bytes | None:
42+
try:
43+
padded = text + "=" * (-len(text) % 4)
44+
key = base64.urlsafe_b64decode(padded.encode("ascii"))
45+
except (ValueError, UnicodeEncodeError):
46+
return None
47+
if len(key) != KEY_BYTES:
48+
return None
49+
return key
50+
51+
52+
def encrypt_session_dict(session_data: dict[str, Any], key: bytes) -> bytes:
53+
if len(key) != KEY_BYTES:
54+
raise SessionCryptoError(f"Session key must be {KEY_BYTES} bytes.")
55+
try:
56+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
57+
except ImportError as err:
58+
raise SessionCryptoError(
59+
"Session encryption requires the cryptography package (pip install cryptography)."
60+
) from err
61+
62+
plaintext = json.dumps(session_data, ensure_ascii=False).encode("utf-8")
63+
nonce = os.urandom(12)
64+
ciphertext = AESGCM(key).encrypt(nonce, plaintext, None)
65+
payload = base64.urlsafe_b64encode(nonce + ciphertext).decode("ascii")
66+
return MAGIC + payload.encode("ascii") + b"\n"
67+
68+
69+
def decrypt_session_bytes(data: bytes, key: bytes) -> dict[str, Any]:
70+
if len(key) != KEY_BYTES:
71+
raise SessionCryptoError(f"Session key must be {KEY_BYTES} bytes.")
72+
if not is_encrypted_payload(data):
73+
try:
74+
parsed = json.loads(data.decode("utf-8"))
75+
except json.JSONDecodeError as err:
76+
raise SessionCryptoError("Invalid session file (not JSON).") from err
77+
if not isinstance(parsed, dict):
78+
raise SessionCryptoError("Invalid session format.")
79+
return parsed
80+
81+
body = data[len(MAGIC) :].strip()
82+
if not body:
83+
raise SessionCryptoError("Encrypted session file is empty.")
84+
try:
85+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
86+
except ImportError as err:
87+
raise SessionCryptoError(
88+
"Session encryption requires the cryptography package (pip install cryptography)."
89+
) from err
90+
91+
try:
92+
blob = base64.urlsafe_b64decode(body + b"=" * (-len(body) % 4))
93+
except ValueError as err:
94+
raise SessionCryptoError("Encrypted session payload is invalid.") from err
95+
if len(blob) < 13:
96+
raise SessionCryptoError("Encrypted session payload is too short.")
97+
nonce, ciphertext = blob[:12], blob[12:]
98+
try:
99+
plaintext = AESGCM(key).decrypt(nonce, ciphertext, None)
100+
except Exception as err:
101+
raise SessionCryptoError("Could not decrypt session (wrong key or corrupted file).") from err
102+
try:
103+
parsed = json.loads(plaintext.decode("utf-8"))
104+
except json.JSONDecodeError as err:
105+
raise SessionCryptoError("Decrypted session is not valid JSON.") from err
106+
if not isinstance(parsed, dict):
107+
raise SessionCryptoError("Invalid session format.")
108+
return parsed

cecli/sessions.py

Lines changed: 91 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from typing import Dict, List, Optional
77

88
from cecli import models
9+
from cecli import session_crypto
910
from cecli.helpers.conversation import ConversationService, MessageTag
1011

1112

@@ -22,6 +23,67 @@ def _get_session_directory(self) -> Path:
2223
os.makedirs(session_dir, exist_ok=True)
2324
return session_dir
2425

26+
def _session_encrypt_settings(self) -> tuple[bool, bytes | None]:
27+
args = getattr(self.coder, "args", None)
28+
if not args or not getattr(args, "session_encrypt", False):
29+
return False, None
30+
key_file = getattr(args, "session_key_file", None)
31+
return True, session_crypto.resolve_key(key_file=key_file)
32+
33+
def _read_session_file(self, session_file: Path) -> dict | None:
34+
try:
35+
data = session_file.read_bytes()
36+
except OSError as e:
37+
self.io.tool_error(f"Error reading session: {e}")
38+
return None
39+
try:
40+
if session_crypto.is_encrypted_payload(data):
41+
args = getattr(self.coder, "args", None)
42+
key_file = getattr(args, "session_key_file", None) if args else None
43+
key = session_crypto.resolve_key(key_file=key_file)
44+
if not key:
45+
self.io.tool_error(
46+
"Session is encrypted but no key is configured "
47+
f"({session_crypto.KEY_ENV} or --session-key-file)."
48+
)
49+
return None
50+
return session_crypto.decrypt_session_bytes(data, key)
51+
parsed = json.loads(data.decode("utf-8"))
52+
if not isinstance(parsed, dict):
53+
self.io.tool_error("Invalid session format.")
54+
return None
55+
return parsed
56+
except session_crypto.SessionCryptoError as e:
57+
self.io.tool_error(str(e))
58+
return None
59+
except json.JSONDecodeError as e:
60+
self.io.tool_error(f"Error loading session: {e}")
61+
return None
62+
63+
def _write_session_file(self, session_file: Path, session_data: dict) -> bool:
64+
encrypt_enabled, key = self._session_encrypt_settings()
65+
try:
66+
if encrypt_enabled:
67+
if not key:
68+
self.io.tool_error(
69+
"Session encryption is enabled but no key is configured "
70+
f"({session_crypto.KEY_ENV} or --session-key-file)."
71+
)
72+
return False
73+
session_file.write_bytes(
74+
session_crypto.encrypt_session_dict(session_data, key)
75+
)
76+
else:
77+
with open(session_file, "w", encoding="utf-8") as f:
78+
json.dump(session_data, f, indent=2)
79+
return True
80+
except session_crypto.SessionCryptoError as e:
81+
self.io.tool_error(str(e))
82+
return False
83+
except OSError as e:
84+
self.io.tool_error(f"Error saving session: {e}")
85+
return False
86+
2587
def save_session(self, session_name: str, output=True) -> bool:
2688
"""Save the current chat session to a named file."""
2789
if not session_name:
@@ -39,11 +101,12 @@ def save_session(self, session_name: str, output=True) -> bool:
39101

40102
try:
41103
session_data = self._build_session_data(session_name)
42-
with open(session_file, "w", encoding="utf-8") as f:
43-
json.dump(session_data, f, indent=2)
104+
if not self._write_session_file(session_file, session_data):
105+
return False
44106

45107
if output:
46-
self.io.tool_output(f"Session saved: {session_file}")
108+
suffix = " (encrypted)" if self._session_encrypt_settings()[0] else ""
109+
self.io.tool_output(f"Session saved: {session_file}{suffix}")
47110

48111
return True
49112

@@ -63,8 +126,27 @@ def list_sessions(self) -> List[Dict]:
63126
sessions = []
64127
for session_file in sorted(session_files, key=lambda x: x.stat().st_mtime, reverse=True):
65128
try:
66-
with open(session_file, "r", encoding="utf-8") as f:
67-
session_data = json.load(f)
129+
raw = session_file.read_bytes()
130+
if session_crypto.is_encrypted_payload(raw):
131+
_, key = self._session_encrypt_settings()
132+
if not key:
133+
sessions.append(
134+
{
135+
"name": session_file.stem,
136+
"file": session_file,
137+
"model": "encrypted",
138+
"edit_format": "—",
139+
"num_messages": 0,
140+
"num_files": 0,
141+
"encrypted": True,
142+
}
143+
)
144+
continue
145+
session_data = session_crypto.decrypt_session_bytes(raw, key)
146+
else:
147+
session_data = json.loads(raw.decode("utf-8"))
148+
if not isinstance(session_data, dict):
149+
raise ValueError("not a session object")
68150

69151
session_info = {
70152
"name": session_file.stem,
@@ -80,6 +162,7 @@ def list_sessions(self) -> List[Dict]:
80162
+ len(session_data.get("files", {}).get("read_only", []))
81163
+ len(session_data.get("files", {}).get("read_only_stubs", []))
82164
),
165+
"encrypted": session_crypto.is_encrypted_payload(raw),
83166
}
84167
sessions.append(session_info)
85168

@@ -99,15 +182,12 @@ async def load_session(self, session_identifier: str, switch=True) -> bool:
99182
if not session_file:
100183
return False
101184

102-
try:
103-
with open(session_file, "r", encoding="utf-8") as f:
104-
session_data = json.load(f)
105-
except Exception as e:
106-
self.io.tool_error(f"Error loading session: {e}")
185+
session_data = self._read_session_file(session_file)
186+
if session_data is None:
107187
return False
108188

109189
# Verify session format
110-
if not isinstance(session_data, dict) or "version" not in session_data:
190+
if "version" not in session_data:
111191
self.io.tool_error("Invalid session format.")
112192
return False
113193

cecli/website/docs/usage/sessions.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,17 @@ Sessions are stored as JSON files in the `.cecli/sessions/` directory within you
158158
### Version Control
159159
- Consider adding `.cecli/sessions/` to your `.gitignore` if sessions contain sensitive information
160160

161+
### Optional encryption (AES-256-GCM)
162+
163+
When enabled, session files on disk are encrypted (plaintext JSON is unchanged when disabled).
164+
165+
```bash
166+
export CECLI_SESSION_KEY="$(python -c 'import os,base64; print(base64.urlsafe_b64encode(os.urandom(32)).decode())')"
167+
cecli --session-encrypt --auto-save
168+
```
169+
170+
Or use `--session-key-file` pointing at a file with the same urlsafe-base64 32-byte key. BrightVision stores the key in the OS keychain and sets `CECLI_SESSION_KEY` for the Vision API process.
171+
161172
## Troubleshooting
162173

163174
### Session Not Found

requirements/requirements.in

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ tomlkit>=0.14.0
3232
truststore
3333
xxhash>=3.6.0
3434
py-cymbal>=0.1.24
35+
cryptography>=42.0.0
3536

3637
# Replaced networkx with rustworkx for better performance in repomap
3738
rustworkx>=0.15.0

tests/basic/conftest.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
"""Shared fixtures for cecli basic tests."""
2+
3+
import base64
4+
import os
5+
6+
import pytest
7+
8+
from cecli import session_crypto
9+
10+
11+
@pytest.fixture
12+
def session_key32():
13+
return os.urandom(session_crypto.KEY_BYTES)
14+
15+
16+
@pytest.fixture
17+
def session_key_b64(session_key32):
18+
return base64.urlsafe_b64encode(session_key32).decode().rstrip("=")
19+
20+
21+
@pytest.fixture
22+
def session_key_env(monkeypatch, session_key32, session_key_b64):
23+
monkeypatch.setenv(session_crypto.KEY_ENV, session_key_b64)
24+
return session_key32

tests/basic/test_session_args.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
"""CLI args for session encryption and auto-save."""
2+
3+
from cecli.args import get_parser
4+
5+
6+
def test_session_encrypt_defaults_off():
7+
parser = get_parser([], "/tmp/project")
8+
args = parser.parse_args([])
9+
assert args.session_encrypt is False
10+
assert args.session_key_file is None
11+
assert args.auto_save is False
12+
assert args.auto_load is False
13+
assert args.auto_save_session_name == "auto-save"
14+
15+
16+
def test_session_encrypt_flag():
17+
parser = get_parser([], "/tmp/project")
18+
args = parser.parse_args(["--session-encrypt"])
19+
assert args.session_encrypt is True
20+
21+
22+
def test_session_encrypt_no_flag():
23+
parser = get_parser([], "/tmp/project")
24+
args = parser.parse_args(["--no-session-encrypt"])
25+
assert args.session_encrypt is False
26+
27+
28+
def test_session_key_file_flag():
29+
parser = get_parser([], "/tmp/project")
30+
args = parser.parse_args(["--session-key-file", "/tmp/key.bin"])
31+
assert args.session_key_file == "/tmp/key.bin"

0 commit comments

Comments
 (0)