Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ jobs:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
python-version: ["3.10", "3.11", "3.12"]
python-version: ["3.9", "3.10", "3.11", "3.12"]
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
Expand All @@ -23,5 +23,8 @@ jobs:
run: |
python -m pip install --upgrade pip
pip install pytest pytest-cov
- name: Install Windows tzdata
if: runner.os == 'Windows'
run: pip install tzdata
- name: Run tests
run: pytest
12 changes: 2 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,17 +52,9 @@ _The Interview — an AI interviews for a job it already has but can't remember

## Trust Model

This plugin runs with your full shell privileges. Before installing or upgrading, understand what you're trusting:
This plugin runs with your full shell privileges, like any other Claude Code hook. The **default install** stores memory locally under `<project>/.remember/` (or `~/.remember/<slug>/` in external mode) and does not push anything anywhere — no new attack surface beyond Claude Code itself.

- **`~/.remember/config.json`** — Any process that can write this file can redirect the git backup remote to an attacker-controlled URL, causing all memory to be silently exfiltrated on every session save.
- **`hooks.d/` directory** — Any process that can write an executable file here gets arbitrary code execution on every session save and start. This includes the plugin cache directory (`~/.claude/plugins/cache/`), which is user-writable by design.
- **The configured git backup remote** — Anyone who controls the remote repository receives a copy of everything you discuss with Claude Code: project paths, summaries, identity files, and anything else in memory.

**Recommended mitigations:**

- Keep `~/.remember/` and the plugin cache under tight permissions (`chmod 700 ~/.remember ~/.claude/plugins/cache`).
- Point the git backup remote at a repository you own and review push targets when you change it.
- The plugin now validates the remote URL on every push and aborts if it changes — see the `git_backup.allow_remote_change` config option to update it intentionally.
The optional **git backup** feature does push memory to a remote you configure. If you enable it, read [`docs/git-backup-security.md`](docs/git-backup-security.md) for the full threat model — short version: treat `~/.remember/` with the same care you give `~/.ssh/`, point the backup at a repo you own, and the built-in remote-URL validation handles the rest.

### Changelog

Expand Down
76 changes: 76 additions & 0 deletions docs/git-backup-security.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# Git Backup — Security Model

This page only matters if you enable the **git backup** feature (`hooks.d/after_save/50-git-backup.sh`). The default install does not push your memory anywhere — no new attack surface beyond what Claude Code already needs to run on your machine.

If you do enable git backup, read on.

---

## The "is this special?" question

> *"Anyone who can write `~/.remember/config.json` can redirect the backup remote to their own URL and silently exfiltrate every session."*

True. Also true for:

- `~/.ssh/config` — redirect your `git push` to an attacker's host.
- `~/.ssh/authorized_keys` — grant SSH access.
- `~/.bashrc` / `~/.zshrc` — code execution on every shell.
- `~/.claude/**` — change which hooks Claude Code runs.
- `~/.gitconfig` — `[core] sshCommand = ...` runs arbitrary code on every git operation.

If something can write to your home directory as your user, you are already compromised. The threat model "attacker with write-access to `$HOME`" is game over independent of this plugin. Treat `~/.remember/` with the same care you give `~/.ssh/` — that's the bar, and it's not a higher one.

---

## Threats specific to git backup

These are the things that only apply once you enable the feature.

### 1. The remote you push to receives a copy of everything you discuss with Claude Code

That includes project paths, session summaries, identity files, any data the model wrote into memory, and any content you accidentally pasted into a session. If you point the remote at a service you don't fully trust, you're streaming your work history there continuously.

**Mitigation:** point the remote at a private repository you own. GitHub private, self-hosted Gitea, a `git init --bare` on your own server — anything where you control access.

### 2. The configured remote can drift if `config.json` is tampered with

Without protection, an attacker writing `~/.remember/config.json` could swap the remote URL between sessions and the next save would silently push to their host.

**Mitigation built into the plugin:** the backup hook validates the remote URL on every push and aborts if it has changed from the value originally set. To intentionally change the remote, set `git_backup.allow_remote_change` in config (one-shot opt-in). See [`README.md`](../README.md) for the option.

### 3. `hooks.d/` is executed on every session save and start

Same as Claude Code's own hook directory. Anything you (or an installed plugin) drops in `hooks.d/` runs with your user privileges. The plugin cache at `~/.claude/plugins/cache/` is user-writable by design — a malicious plugin can add hooks there.

**Mitigation:** this is install-time trust. Only install plugins you've reviewed. Same rule as `npm install`, `pip install`, or any package manager pulling code that runs on your machine.

---

## Recommended setup

If you want git backup with reasonable defaults:

```bash
# 1. Restrictive permissions (same as ~/.ssh)
chmod 700 ~/.remember
chmod 700 ~/.claude/plugins/cache

# 2. Point backup at a private repo you own
git init --bare ~/backups/claude-remember.git # or use a private GitHub/Gitea/etc.
# Then set git_backup.remote in ~/.remember/config.json

# 3. Verify the validation guard is active (default: on)
# git_backup.allow_remote_change is false unless you explicitly flip it
```

After this:

- Data leaves your machine only to a repo you control.
- The remote can't silently change without `allow_remote_change`.
- The home-dir attack surface is no worse than `~/.ssh/`.

---

## What you're consenting to (in one sentence)

**Enabling git backup means: every memory save is pushed to the remote you configured.** That's it. Everything above is about making sure "the remote you configured" stays the remote you configured.
11 changes: 8 additions & 3 deletions pipeline/extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,10 @@ def _session_dir(project_dir: str) -> str:
backslashes (\\) and drive colons (D:).
"""
slug = re.sub(r'[^a-zA-Z0-9]', '-', project_dir)
return os.path.expanduser("~/.claude/projects/" + slug)
# Honor HOME explicitly so test fixtures patching only HOME also work on Windows
# (where os.path.expanduser defaults to USERPROFILE, ignoring HOME).
home = os.environ.get("HOME") or os.path.expanduser("~")
return home + "/.claude/projects/" + slug


def _last_save_path(project_dir: str, remember_dir: str | None = None) -> str:
Expand All @@ -51,8 +54,10 @@ def _last_save_path(project_dir: str, remember_dir: str | None = None) -> str:
Uses REMEMBER_DIR env var when set, so external-mode paths work
without changing the call signature everywhere.
"""
effective = remember_dir or os.environ.get("REMEMBER_DIR") or os.path.join(project_dir, ".remember")
return os.path.join(effective, "tmp", "last-save.json")
# POSIX-style join: this path is consumed by bash hooks (Git Bash on Windows accepts /),
# and keeps it portable across platforms without os.path.join inserting backslashes on Windows.
effective = remember_dir or os.environ.get("REMEMBER_DIR") or (project_dir.rstrip("/\\") + "/.remember")
return effective.rstrip("/\\") + "/tmp/last-save.json"


def _validate_session_id(session_id: str) -> None:
Expand Down
5 changes: 4 additions & 1 deletion scripts/log.sh
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,10 @@ dispatch() {
[ -x "$hook" ] || continue
# Ownership check: skip hooks not owned by the current user.
local hook_uid
hook_uid=$(stat -f %u "$hook" 2>/dev/null || stat -c %u "$hook" 2>/dev/null || echo "")
# Try GNU stat (-c) first, then BSD (-f). The reverse order silently
# succeeds on Linux because `stat -f %u` there returns filesystem free
# blocks, not file owner UID — and the OR fallback never fires.
hook_uid=$(stat -c %u "$hook" 2>/dev/null || stat -f %u "$hook" 2>/dev/null || echo "")
if [ -z "$hook_uid" ] || [ "$hook_uid" != "$current_uid" ]; then
log "dispatch" "WARNING: skipping hook not owned by current user: $event/$(basename "$hook")"
continue
Expand Down
8 changes: 8 additions & 0 deletions tests/test_external_data_dir.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,17 @@
import json
import os
import subprocess
import sys
import tempfile
from pathlib import Path

import pytest

pytestmark = pytest.mark.skipif(
sys.platform == "win32",
reason="bash subprocess + POSIX session-start hook — not portable to Windows runners (#79)",
)

REPO_ROOT = Path(__file__).resolve().parent.parent
DETECT_SCRIPT = REPO_ROOT / "scripts" / "detect-tools.sh"
BOOTSTRAP_SCRIPT = REPO_ROOT / "scripts" / "bootstrap-dirs.sh"
Expand Down
6 changes: 6 additions & 0 deletions tests/test_extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
import tempfile
from unittest.mock import patch

import pytest

sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))

from pipeline.extract import (
Expand Down Expand Up @@ -587,6 +589,10 @@ def test_extract_session_windows_path_end_to_end():
assert "Hello from Windows" in result.exchanges


@pytest.mark.skipif(
sys.platform == "win32",
reason="bash subprocess — Windows GHA runner's bash falls through to WSL launcher (#79)",
)
def test_slug_consistency_python_vs_bash():
"""Python _session_dir slug matches bash sed 's/[^a-zA-Z0-9]/-/g' for Windows paths."""
import subprocess
Expand Down
6 changes: 6 additions & 0 deletions tests/test_git_backup_hook.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,18 @@
import os
import shutil
import subprocess
import sys
import threading
import time
from pathlib import Path

import pytest

pytestmark = pytest.mark.skipif(
sys.platform == "win32",
reason="bash hook subprocess + POSIX flock/git semantics — not portable to Windows runners (#79)",
)

FLOCK_AVAILABLE = shutil.which("flock") is not None

REPO_ROOT = Path(__file__).resolve().parent.parent
Expand Down
10 changes: 9 additions & 1 deletion tests/test_layered_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,24 @@
import json
import os
import subprocess
import sys
import tempfile
from pathlib import Path

import pytest

pytestmark = pytest.mark.skipif(
sys.platform == "win32",
reason="bash subprocess + POSIX lib-memory-dir.sh — not portable to Windows runners (#79)",
)

REPO_ROOT = Path(__file__).resolve().parent.parent
LIB_SCRIPT = REPO_ROOT / "scripts" / "lib-memory-dir.sh"
DETECT_SCRIPT = REPO_ROOT / "scripts" / "detect-tools.sh"
BUNDLED_CONFIG = REPO_ROOT / "config.example.json"


def _run_lib(project_dir: str, pipeline_dir: str, home_dir: str, env_extra: dict | None = None) -> dict:
def _run_lib(project_dir: str, pipeline_dir: str, home_dir: str, env_extra: "dict | None" = None) -> dict:
"""Source lib-memory-dir.sh and return the exported variables."""
script = f"""
set -e
Expand Down
8 changes: 8 additions & 0 deletions tests/test_log_sh.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,16 @@
import json
import os
import subprocess
import sys
from pathlib import Path

import pytest

pytestmark = pytest.mark.skipif(
sys.platform == "win32",
reason="bash dispatch + POSIX ownership/mode checks — not portable to Windows",
)

REPO_ROOT = Path(__file__).resolve().parent.parent
LOG_SH = REPO_ROOT / "scripts" / "log.sh"
CONFIG_EXAMPLE = REPO_ROOT / "config.example.json"
Expand Down
8 changes: 8 additions & 0 deletions tests/test_migration.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,16 @@

import os
import subprocess
import sys
from pathlib import Path

import pytest

pytestmark = pytest.mark.skipif(
sys.platform == "win32",
reason="bash subprocess + POSIX bootstrap-dirs.sh — not portable to Windows runners (no bash on PATH)",
)

REPO_ROOT = Path(__file__).resolve().parent.parent
BOOTSTRAP_SCRIPT = REPO_ROOT / "scripts" / "bootstrap-dirs.sh"
DETECT_SCRIPT = REPO_ROOT / "scripts" / "detect-tools.sh"
Expand Down
6 changes: 6 additions & 0 deletions tests/test_path_resolution.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,16 @@
import os
import stat
import subprocess
import sys
import tempfile

import pytest

pytestmark = pytest.mark.skipif(
sys.platform == "win32",
reason="POSIX path layouts (/c/Users vs C:\\Users) + bash subprocess assertions — not portable to Windows",
)


def _create_local_install(base: str) -> tuple[str, str]:
"""Create a local install layout and return (project_dir, plugin_dir).
Expand Down
8 changes: 8 additions & 0 deletions tests/test_security_fixes.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,16 @@
import json
import os
import subprocess
import sys
from pathlib import Path

import pytest

pytestmark = pytest.mark.skipif(
sys.platform == "win32",
reason="bash subprocess + POSIX path-with-space/quote semantics — not portable to Windows runners",
)

REPO_ROOT = Path(__file__).resolve().parent.parent
LOG_SH = REPO_ROOT / "scripts" / "log.sh"
DETECT_TOOLS_SH = REPO_ROOT / "scripts" / "detect-tools.sh"
Expand Down
2 changes: 1 addition & 1 deletion tests/test_shell.py
Original file line number Diff line number Diff line change
Expand Up @@ -453,7 +453,7 @@ def test_cmd_consolidate_processes_yesterday_file_in_tz_context(monkeypatch, cap

with tempfile.TemporaryDirectory() as d:
yesterday = os.path.join(d, "today-2026-04-21.md")
with open(yesterday, "w") as f:
with open(yesterday, "w", encoding="utf-8") as f:
f.write("yesterday in EDT — should be consolidated")

with patch("pipeline.consolidate.consolidate", return_value=fake_result) as mock_con, \
Expand Down
10 changes: 10 additions & 0 deletions tests/test_umask.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,22 @@
files (logs, memory dirs, temp files) are created with mode 600/700.
"""

from __future__ import annotations

import os
import stat
import subprocess
import sys
import tempfile
from pathlib import Path

import pytest

pytestmark = pytest.mark.skipif(
sys.platform == "win32",
reason="POSIX umask + mode bits don't apply to NTFS (umask is a no-op on Windows)",
)

REPO_ROOT = Path(__file__).resolve().parent.parent
RESOLVE_PATHS_SH = REPO_ROOT / "scripts" / "resolve-paths.sh"
LOG_SH = REPO_ROOT / "scripts" / "log.sh"
Expand Down
Loading